mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 01:24:12 +08:00
🐛 fix(nacos): 完善多版本连接与配置服务可靠性
- 支持 Nacos v1、v2、v3 就绪探测与受限账号连接 - 强化认证、缓存、单飞连接和关闭代际的并发边界 - 修复监听重复事件、导入身份碰撞及部分失败误报成功 - 补齐只读保护、结构化错误与六语言提示
This commit is contained in:
@@ -29,6 +29,7 @@ var connectionReadOnlySupportedTypes = map[string]struct{}{
|
||||
"mariadb": {},
|
||||
"mongodb": {},
|
||||
"mysql": {},
|
||||
"nacos": {},
|
||||
"oceanbase": {},
|
||||
"opengauss": {},
|
||||
"oracle": {},
|
||||
|
||||
@@ -15,6 +15,9 @@ func TestSupportsConnectionReadOnlyMode(t *testing.T) {
|
||||
if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "mongodb"}) {
|
||||
t.Fatal("mongodb should support connection-level production guard")
|
||||
}
|
||||
if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "nacos"}) {
|
||||
t.Fatal("nacos should support connection-level production guard")
|
||||
}
|
||||
if supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "redis"}) {
|
||||
t.Fatal("redis should not support connection-level production guard")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,15 +17,29 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/nacos"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var (
|
||||
nacosCache = make(map[string]nacos.Client)
|
||||
nacosCacheConfigs = make(map[string]connection.ConnectionConfig)
|
||||
nacosCacheMu sync.Mutex
|
||||
newNacosClientFunc = nacos.NewClient
|
||||
nacosCache = make(map[string]nacos.Client)
|
||||
nacosCacheMu sync.Mutex
|
||||
nacosCacheGeneration uint64
|
||||
nacosCacheGenerationCtx context.Context
|
||||
nacosCacheGenerationCancel context.CancelFunc
|
||||
nacosConnectGroup singleflight.Group
|
||||
newNacosClientFunc = nacos.NewClient
|
||||
)
|
||||
|
||||
var errNacosCacheInvalidated = errors.New("Nacos 连接缓存已关闭")
|
||||
|
||||
const defaultNacosOperationTimeoutSeconds = 30
|
||||
|
||||
const nacosNamespaceListForbiddenErrorCode = "nacos_namespace_list_forbidden"
|
||||
|
||||
func init() {
|
||||
nacosCacheGenerationCtx, nacosCacheGenerationCancel = context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
// NacosConfigQuery is the frontend search payload.
|
||||
type NacosConfigQuery struct {
|
||||
NamespaceID string `json:"namespaceId"`
|
||||
@@ -50,6 +68,7 @@ type NacosPublishConfigPayload struct {
|
||||
type NacosConfigIdentity struct {
|
||||
DataID string `json:"dataId"`
|
||||
Group string `json:"group"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
// NacosExportConfigsOptions controls config export.
|
||||
@@ -105,6 +124,7 @@ type NacosServicePayload struct {
|
||||
NamespaceID string `json:"namespaceId"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
GroupName string `json:"groupName,omitempty"`
|
||||
Ephemeral *bool `json:"ephemeral,omitempty"`
|
||||
ProtectThreshold float64 `json:"protectThreshold,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -126,7 +146,7 @@ type NacosInstancePayload struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
ClusterName string `json:"clusterName,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Healthy *bool `json:"healthy,omitempty"`
|
||||
Ephemeral *bool `json:"ephemeral,omitempty"`
|
||||
@@ -146,35 +166,121 @@ func formatNacosConnSummary(config connection.ConnectionConfig) string {
|
||||
b.WriteString(" 用户=")
|
||||
b.WriteString(user)
|
||||
}
|
||||
if params := strings.TrimSpace(config.ConnectionParams); params != "" {
|
||||
b.WriteString(" params=")
|
||||
b.WriteString(params)
|
||||
if contextPath := nacosContextPathForSummary(config.ConnectionParams); contextPath != "" {
|
||||
b.WriteString(" contextPath=")
|
||||
b.WriteString(contextPath)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func nacosContextPathForSummary(raw string) string {
|
||||
normalized := strings.NewReplacer(";", "&", "\r", "&", "\n", "&").Replace(raw)
|
||||
values, _ := url.ParseQuery(normalized)
|
||||
contextPath := strings.TrimSpace(values.Get("contextPath"))
|
||||
if contextPath == "" {
|
||||
return ""
|
||||
}
|
||||
for _, char := range contextPath {
|
||||
if char < 0x20 || char == 0x7f {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if contextPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(contextPath, "/") {
|
||||
contextPath = "/" + contextPath
|
||||
}
|
||||
return strings.TrimRight(contextPath, "/")
|
||||
}
|
||||
|
||||
func getNacosClientCacheKey(config connection.ConnectionConfig) string {
|
||||
normalized := normalizeCacheKeyConfig(config)
|
||||
raw := strings.Join([]string{
|
||||
"nacos",
|
||||
strings.TrimSpace(normalized.Host),
|
||||
strconv.Itoa(normalized.Port),
|
||||
strings.TrimSpace(normalized.User),
|
||||
strconv.FormatBool(normalized.UseSSL),
|
||||
strings.TrimSpace(normalized.SSLMode),
|
||||
strings.TrimSpace(normalized.ConnectionParams),
|
||||
strings.TrimSpace(normalized.Database),
|
||||
strconv.FormatBool(normalized.UseProxy),
|
||||
strings.TrimSpace(normalized.Proxy.Type),
|
||||
strings.TrimSpace(normalized.Proxy.Host),
|
||||
strconv.Itoa(normalized.Proxy.Port),
|
||||
strings.TrimSpace(normalized.Proxy.User),
|
||||
}, "|")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
identity := struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
UseSSL bool `json:"useSSL"`
|
||||
SSLMode string `json:"sslMode"`
|
||||
SSLCAPath string `json:"sslCAPath"`
|
||||
SSLCertPath string `json:"sslCertPath"`
|
||||
SSLKeyPath string `json:"sslKeyPath"`
|
||||
ConnectionParams string `json:"connectionParams"`
|
||||
Database string `json:"database"`
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSHHost string `json:"sshHost"`
|
||||
SSHPort int `json:"sshPort"`
|
||||
SSHUser string `json:"sshUser"`
|
||||
SSHPassword string `json:"sshPassword"`
|
||||
SSHKeyPath string `json:"sshKeyPath"`
|
||||
UseProxy bool `json:"useProxy"`
|
||||
ProxyType string `json:"proxyType"`
|
||||
ProxyHost string `json:"proxyHost"`
|
||||
ProxyPort int `json:"proxyPort"`
|
||||
ProxyUser string `json:"proxyUser"`
|
||||
ProxyPassword string `json:"proxyPassword"`
|
||||
UseHTTPTunnel bool `json:"useHttpTunnel"`
|
||||
HTTPTunnelHost string `json:"httpTunnelHost"`
|
||||
HTTPTunnelPort int `json:"httpTunnelPort"`
|
||||
HTTPTunnelUser string `json:"httpTunnelUser"`
|
||||
HTTPTunnelPassword string `json:"httpTunnelPassword"`
|
||||
}{
|
||||
Type: "nacos",
|
||||
Host: strings.TrimSpace(normalized.Host),
|
||||
Port: normalized.Port,
|
||||
User: strings.TrimSpace(normalized.User),
|
||||
Password: normalized.Password,
|
||||
UseSSL: normalized.UseSSL,
|
||||
SSLMode: strings.TrimSpace(normalized.SSLMode),
|
||||
SSLCAPath: strings.TrimSpace(normalized.SSLCAPath),
|
||||
SSLCertPath: strings.TrimSpace(normalized.SSLCertPath),
|
||||
SSLKeyPath: strings.TrimSpace(normalized.SSLKeyPath),
|
||||
ConnectionParams: strings.TrimSpace(normalized.ConnectionParams),
|
||||
Database: strings.TrimSpace(normalized.Database),
|
||||
UseSSH: normalized.UseSSH,
|
||||
SSHHost: strings.TrimSpace(normalized.SSH.Host),
|
||||
SSHPort: normalized.SSH.Port,
|
||||
SSHUser: strings.TrimSpace(normalized.SSH.User),
|
||||
SSHPassword: normalized.SSH.Password,
|
||||
SSHKeyPath: strings.TrimSpace(normalized.SSH.KeyPath),
|
||||
UseProxy: normalized.UseProxy,
|
||||
ProxyType: strings.TrimSpace(normalized.Proxy.Type),
|
||||
ProxyHost: strings.TrimSpace(normalized.Proxy.Host),
|
||||
ProxyPort: normalized.Proxy.Port,
|
||||
ProxyUser: strings.TrimSpace(normalized.Proxy.User),
|
||||
ProxyPassword: normalized.Proxy.Password,
|
||||
UseHTTPTunnel: normalized.UseHTTPTunnel,
|
||||
HTTPTunnelHost: strings.TrimSpace(normalized.HTTPTunnel.Host),
|
||||
HTTPTunnelPort: normalized.HTTPTunnel.Port,
|
||||
HTTPTunnelUser: strings.TrimSpace(normalized.HTTPTunnel.User),
|
||||
HTTPTunnelPassword: normalized.HTTPTunnel.Password,
|
||||
}
|
||||
raw, _ := json.Marshal(identity)
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (a *App) getNacosClient(config connection.ConnectionConfig) (nacos.Client, error) {
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
return a.getNacosClientWithContext(ctx, config)
|
||||
}
|
||||
|
||||
func (a *App) getNacosClientWithContext(ctx context.Context, config connection.ConnectionConfig) (nacos.Client, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("Nacos 连接上下文不能为空")
|
||||
}
|
||||
|
||||
nacosCacheMu.Lock()
|
||||
requestGeneration := nacosCacheGeneration
|
||||
requestGenerationCtx := nacosCacheGenerationCtx
|
||||
nacosCacheMu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolvedConfig, err := a.resolveConnectionSecrets(config)
|
||||
if err != nil {
|
||||
wrapped := wrapConnectError(config, err)
|
||||
@@ -189,39 +295,101 @@ func (a *App) getNacosClient(config connection.ConnectionConfig) (nacos.Client,
|
||||
return nil, wrapped
|
||||
}
|
||||
connectConfig.Type = "nacos"
|
||||
|
||||
key := getNacosClientCacheKey(connectConfig)
|
||||
shortKey := key
|
||||
if len(shortKey) > 12 {
|
||||
shortKey = shortKey[:12]
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nacosCacheMu.Lock()
|
||||
defer nacosCacheMu.Unlock()
|
||||
cacheIdentityConfig := resolvedConfig
|
||||
cacheIdentityConfig.Type = "nacos"
|
||||
key := getNacosClientCacheKey(cacheIdentityConfig)
|
||||
|
||||
if client, ok := nacosCache[key]; ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx); err == nil {
|
||||
return client, nil
|
||||
} else {
|
||||
logger.Error(err, "缓存 Nacos 连接不可用,准备重建:缓存Key=%s", shortKey)
|
||||
_ = client.Close()
|
||||
delete(nacosCache, key)
|
||||
delete(nacosCacheConfigs, key)
|
||||
flightKey := strconv.FormatUint(requestGeneration, 10) + ":" + key + ":" +
|
||||
strconv.Itoa(nacosOperationTimeoutSeconds(connectConfig))
|
||||
|
||||
resultCh := nacosConnectGroup.DoChan(flightKey, func() (any, error) {
|
||||
if requestGenerationCtx.Err() != nil {
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
}
|
||||
nacosCacheMu.Lock()
|
||||
if nacosCacheGeneration != requestGeneration {
|
||||
nacosCacheMu.Unlock()
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
cachedClient := nacosCache[key]
|
||||
nacosCacheMu.Unlock()
|
||||
|
||||
client := newNacosClientFunc()
|
||||
if err := client.Connect(connectConfig); err != nil {
|
||||
_ = client.Close()
|
||||
wrapped := wrapConnectError(connectConfig, err)
|
||||
logger.Error(wrapped, "Nacos 连接失败:%s 缓存Key=%s", formatNacosConnSummary(connectConfig), shortKey)
|
||||
return nil, wrapped
|
||||
if cachedClient != nil {
|
||||
// net/http transports reconnect on demand. Returning the published
|
||||
// client directly also prevents timeout-specific flights from racing
|
||||
// to evict and close the same cached client.
|
||||
return cachedClient, nil
|
||||
}
|
||||
|
||||
// Another cache publisher may have won after this timeout-specific cold
|
||||
// connection flight started. Recheck before opening a physical client.
|
||||
nacosCacheMu.Lock()
|
||||
if nacosCacheGeneration != requestGeneration {
|
||||
nacosCacheMu.Unlock()
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
cachedClient = nacosCache[key]
|
||||
nacosCacheMu.Unlock()
|
||||
if cachedClient != nil {
|
||||
return cachedClient, nil
|
||||
}
|
||||
if requestGenerationCtx.Err() != nil {
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
|
||||
client := newNacosClientFunc()
|
||||
if err := client.Connect(connectConfig); err != nil {
|
||||
_ = client.Close()
|
||||
wrapped := wrapConnectError(connectConfig, err)
|
||||
logger.Error(wrapped, "Nacos 连接失败:%s", formatNacosConnSummary(connectConfig))
|
||||
return nil, wrapped
|
||||
}
|
||||
if requestGenerationCtx.Err() != nil {
|
||||
_ = client.Close()
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
|
||||
nacosCacheMu.Lock()
|
||||
cacheInvalidated := nacosCacheGeneration != requestGeneration
|
||||
if !cacheInvalidated {
|
||||
cachedClient = nacosCache[key]
|
||||
}
|
||||
if !cacheInvalidated && cachedClient == nil {
|
||||
nacosCache[key] = client
|
||||
}
|
||||
nacosCacheMu.Unlock()
|
||||
if cacheInvalidated {
|
||||
_ = client.Close()
|
||||
return nil, errNacosCacheInvalidated
|
||||
}
|
||||
if cachedClient != nil {
|
||||
// Defensive loser cleanup: a cache writer outside this keyed flight
|
||||
// must never leave an unpublished physical client alive.
|
||||
_ = client.Close()
|
||||
return cachedClient, nil
|
||||
}
|
||||
|
||||
logger.Infof("Nacos 连接成功并写入缓存:%s", formatNacosConnSummary(connectConfig))
|
||||
return client, nil
|
||||
})
|
||||
|
||||
var result singleflight.Result
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result = <-resultCh:
|
||||
}
|
||||
if result.Err != nil {
|
||||
return nil, result.Err
|
||||
}
|
||||
client, ok := result.Val.(nacos.Client)
|
||||
if !ok || client == nil {
|
||||
return nil, fmt.Errorf("Nacos 连接缓存返回了无效实例")
|
||||
}
|
||||
nacosCache[key] = client
|
||||
nacosCacheConfigs[key] = normalizeCacheKeyConfig(connectConfig)
|
||||
logger.Infof("Nacos 连接成功并写入缓存:%s 缓存Key=%s", formatNacosConnSummary(connectConfig), shortKey)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -250,17 +418,25 @@ func (a *App) openNacosClientIsolated(config connection.ConnectionConfig) (nacos
|
||||
}
|
||||
|
||||
func (a *App) nacosOperationContext(config connection.ConnectionConfig) (context.Context, context.CancelFunc) {
|
||||
timeout := config.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
return context.WithTimeout(
|
||||
context.Background(),
|
||||
time.Duration(nacosOperationTimeoutSeconds(config))*time.Second,
|
||||
)
|
||||
}
|
||||
|
||||
func nacosOperationTimeoutSeconds(config connection.ConnectionConfig) int {
|
||||
if config.Timeout <= 0 {
|
||||
return defaultNacosOperationTimeoutSeconds
|
||||
}
|
||||
return context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
return config.Timeout
|
||||
}
|
||||
|
||||
// NacosConnect establishes and caches a Nacos connection.
|
||||
func (a *App) NacosConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
_, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
_, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosConnect 连接失败:%s", formatNacosConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -293,16 +469,22 @@ func (a *App) NacosTestConnection(config connection.ConnectionConfig) connection
|
||||
// NacosListNamespaces lists namespaces for a connection.
|
||||
func (a *App) NacosListNamespaces(config connection.ConnectionConfig) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
namespaces, err := client.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosListNamespaces 失败:%s", formatNacosConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
result := connection.QueryResult{Success: false, Message: err.Error()}
|
||||
if status, ok := nacos.HTTPStatusCode(err); ok && status == http.StatusForbidden {
|
||||
result.Data = map[string]any{
|
||||
"errorCode": nacosNamespaceListForbiddenErrorCode,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: namespaces}
|
||||
}
|
||||
@@ -310,12 +492,12 @@ func (a *App) NacosListNamespaces(config connection.ConnectionConfig) connection
|
||||
// NacosListConfigGroups lists unique config groups under a namespace.
|
||||
func (a *App) NacosListConfigGroups(config connection.ConnectionConfig, namespaceID string) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
groups, err := client.ListConfigGroups(ctx, namespaceID)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosListConfigGroups 失败:%s", formatNacosConnSummary(config))
|
||||
@@ -327,12 +509,12 @@ func (a *App) NacosListConfigGroups(config connection.ConnectionConfig, namespac
|
||||
// NacosSearchConfigs searches configs under a namespace.
|
||||
func (a *App) NacosSearchConfigs(config connection.ConnectionConfig, query NacosConfigQuery) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
page, err := client.SearchConfigs(ctx, nacos.ConfigQuery{
|
||||
NamespaceID: query.NamespaceID,
|
||||
DataID: query.DataID,
|
||||
@@ -352,12 +534,12 @@ func (a *App) NacosSearchConfigs(config connection.ConnectionConfig, query Nacos
|
||||
// NacosGetConfig loads one config.
|
||||
func (a *App) NacosGetConfig(config connection.ConnectionConfig, namespaceID, group, dataID string) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
detail, err := client.GetConfig(ctx, namespaceID, group, dataID)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosGetConfig 失败:dataId=%s group=%s", dataID, group)
|
||||
@@ -372,12 +554,12 @@ func (a *App) NacosPublishConfig(config connection.ConnectionConfig, payload Nac
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.PublishConfig(ctx, nacos.PublishRequest{
|
||||
NamespaceID: payload.NamespaceID,
|
||||
DataID: payload.DataID,
|
||||
@@ -406,12 +588,12 @@ func (a *App) NacosPublishConfig(config connection.ConnectionConfig, payload Nac
|
||||
// NacosGetBetaConfig loads beta config for one dataId/group.
|
||||
func (a *App) NacosGetBetaConfig(config connection.ConnectionConfig, namespaceID, group, dataID string) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
detail, err := client.GetBetaConfig(ctx, namespaceID, group, dataID)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosGetBetaConfig 失败:dataId=%s group=%s", dataID, group)
|
||||
@@ -426,12 +608,12 @@ func (a *App) NacosStopBetaConfig(config connection.ConnectionConfig, namespaceI
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.StopBetaConfig(ctx, namespaceID, group, dataID); err != nil {
|
||||
logger.Error(err, "NacosStopBetaConfig 失败:dataId=%s group=%s", dataID, group)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -448,12 +630,12 @@ func (a *App) NacosDeleteConfig(config connection.ConnectionConfig, namespaceID,
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.DeleteConfig(ctx, namespaceID, group, dataID); err != nil {
|
||||
logger.Error(err, "NacosDeleteConfig 失败:dataId=%s group=%s", dataID, group)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -470,12 +652,12 @@ func (a *App) NacosCreateNamespace(config connection.ConnectionConfig, payload N
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.CreateNamespace(ctx, nacos.CreateNamespaceRequest{
|
||||
ID: payload.ID,
|
||||
ShowName: payload.ShowName,
|
||||
@@ -496,12 +678,12 @@ func (a *App) NacosUpdateNamespace(config connection.ConnectionConfig, payload N
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.UpdateNamespace(ctx, nacos.UpdateNamespaceRequest{
|
||||
ID: payload.ID,
|
||||
ShowName: payload.ShowName,
|
||||
@@ -522,12 +704,12 @@ func (a *App) NacosDeleteNamespace(config connection.ConnectionConfig, namespace
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.DeleteNamespace(ctx, namespaceID); err != nil {
|
||||
logger.Error(err, "NacosDeleteNamespace 失败:id=%s", namespaceID)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -541,12 +723,12 @@ func (a *App) NacosDeleteNamespace(config connection.ConnectionConfig, namespace
|
||||
// NacosListConfigHistory lists history for one config.
|
||||
func (a *App) NacosListConfigHistory(config connection.ConnectionConfig, query NacosHistoryQuery) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
page, err := client.ListConfigHistory(ctx, nacos.HistoryQuery{
|
||||
NamespaceID: query.NamespaceID,
|
||||
DataID: query.DataID,
|
||||
@@ -564,12 +746,12 @@ func (a *App) NacosListConfigHistory(config connection.ConnectionConfig, query N
|
||||
// NacosGetConfigHistory loads one history detail.
|
||||
func (a *App) NacosGetConfigHistory(config connection.ConnectionConfig, namespaceID, group, dataID, nid string) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
item, err := client.GetConfigHistory(ctx, namespaceID, group, dataID, nid)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosGetConfigHistory 失败:nid=%s dataId=%s", nid, dataID)
|
||||
@@ -581,12 +763,12 @@ func (a *App) NacosGetConfigHistory(config connection.ConnectionConfig, namespac
|
||||
// NacosListServices lists services under a namespace.
|
||||
func (a *App) NacosListServices(config connection.ConnectionConfig, query NacosServiceQuery) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
page, err := client.ListServices(ctx, nacos.ServiceQuery{
|
||||
NamespaceID: query.NamespaceID,
|
||||
GroupName: query.GroupName,
|
||||
@@ -603,12 +785,12 @@ func (a *App) NacosListServices(config connection.ConnectionConfig, query NacosS
|
||||
// NacosGetService loads service detail.
|
||||
func (a *App) NacosGetService(config connection.ConnectionConfig, namespaceID, serviceName, groupName string) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
detail, err := client.GetService(ctx, namespaceID, serviceName, groupName)
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosGetService 失败:service=%s", serviceName)
|
||||
@@ -623,16 +805,17 @@ func (a *App) NacosCreateService(config connection.ConnectionConfig, payload Nac
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.CreateService(ctx, nacos.CreateServiceRequest{
|
||||
NamespaceID: payload.NamespaceID,
|
||||
ServiceName: payload.ServiceName,
|
||||
GroupName: payload.GroupName,
|
||||
Ephemeral: payload.Ephemeral,
|
||||
ProtectThreshold: payload.ProtectThreshold,
|
||||
Metadata: payload.Metadata,
|
||||
}); err != nil {
|
||||
@@ -648,12 +831,12 @@ func (a *App) NacosUpdateService(config connection.ConnectionConfig, payload Nac
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.UpdateService(ctx, nacos.UpdateServiceRequest{
|
||||
NamespaceID: payload.NamespaceID,
|
||||
ServiceName: payload.ServiceName,
|
||||
@@ -673,12 +856,12 @@ func (a *App) NacosDeleteService(config connection.ConnectionConfig, namespaceID
|
||||
if err := a.ensureNacosStructureEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.DeleteService(ctx, namespaceID, serviceName, groupName); err != nil {
|
||||
logger.Error(err, "NacosDeleteService 失败:service=%s", serviceName)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -689,12 +872,12 @@ func (a *App) NacosDeleteService(config connection.ConnectionConfig, namespaceID
|
||||
// NacosListInstances lists instances of a service.
|
||||
func (a *App) NacosListInstances(config connection.ConnectionConfig, query NacosInstanceQuery) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
list, err := client.ListInstances(ctx, nacos.InstanceQuery{
|
||||
NamespaceID: query.NamespaceID,
|
||||
ServiceName: query.ServiceName,
|
||||
@@ -712,12 +895,12 @@ func (a *App) NacosListInstances(config connection.ConnectionConfig, query Nacos
|
||||
// NacosGetInstance loads one instance.
|
||||
func (a *App) NacosGetInstance(config connection.ConnectionConfig, payload NacosInstancePayload) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
inst, err := client.GetInstance(ctx, toNacosInstanceRequest(payload))
|
||||
if err != nil {
|
||||
logger.Error(err, "NacosGetInstance 失败:%s:%d", payload.IP, payload.Port)
|
||||
@@ -732,12 +915,12 @@ func (a *App) NacosRegisterInstance(config connection.ConnectionConfig, payload
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.RegisterInstance(ctx, toNacosInstanceRequest(payload)); err != nil {
|
||||
logger.Error(err, "NacosRegisterInstance 失败:%s:%d", payload.IP, payload.Port)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -751,12 +934,12 @@ func (a *App) NacosUpdateInstance(config connection.ConnectionConfig, payload Na
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.UpdateInstance(ctx, toNacosInstanceRequest(payload)); err != nil {
|
||||
logger.Error(err, "NacosUpdateInstance 失败:%s:%d", payload.IP, payload.Port)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -770,12 +953,12 @@ func (a *App) NacosDeregisterInstance(config connection.ConnectionConfig, payloa
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.DeregisterInstance(ctx, toNacosInstanceRequest(payload)); err != nil {
|
||||
logger.Error(err, "NacosDeregisterInstance 失败:%s:%d", payload.IP, payload.Port)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -789,12 +972,12 @@ func (a *App) NacosUpdateInstanceHealth(config connection.ConnectionConfig, payl
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
if err := client.UpdateInstanceHealth(ctx, toNacosInstanceRequest(payload)); err != nil {
|
||||
logger.Error(err, "NacosUpdateInstanceHealth 失败:%s:%d", payload.IP, payload.Port)
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -819,7 +1002,7 @@ func toNacosInstanceRequest(payload NacosInstancePayload) nacos.InstanceRequest
|
||||
}
|
||||
|
||||
func (a *App) ensureNacosDataEditAllowed(config connection.ConnectionConfig) error {
|
||||
// Nacos is outside the SQL read-only type set; honor explicit flags directly.
|
||||
// Keep the Nacos-specific message while honoring the shared production guard.
|
||||
if config.ReadOnly || config.Protection.RestrictDataEdit {
|
||||
return fmt.Errorf("%s", a.appText("nacos.backend.error.read_only", nil))
|
||||
}
|
||||
@@ -827,7 +1010,7 @@ func (a *App) ensureNacosDataEditAllowed(config connection.ConnectionConfig) err
|
||||
}
|
||||
|
||||
func (a *App) ensureNacosStructureEditAllowed(config connection.ConnectionConfig) error {
|
||||
if config.ReadOnly || config.Protection.RestrictStructureEdit || config.Protection.RestrictDataEdit {
|
||||
if config.ReadOnly || config.Protection.RestrictStructureEdit {
|
||||
return fmt.Errorf("%s", a.appText("nacos.backend.error.read_only", nil))
|
||||
}
|
||||
return nil
|
||||
|
||||
1122
internal/app/methods_nacos_cache_test.go
Normal file
1122
internal/app/methods_nacos_cache_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,8 +52,12 @@ type nacosListenSession struct {
|
||||
}
|
||||
|
||||
var (
|
||||
nacosListenMu sync.Mutex
|
||||
nacosListenSessions = make(map[string]*nacosListenSession)
|
||||
nacosListenMu sync.Mutex
|
||||
nacosListenSessions = make(map[string]*nacosListenSession)
|
||||
nacosListenGeneration uint64
|
||||
nacosListenGenerationCtx, nacosListenGenerationCancel = context.WithCancel(context.Background())
|
||||
nacosListenClosingCount uint64
|
||||
nacosListenStartAfterConnectHook func()
|
||||
)
|
||||
|
||||
// NacosStartConfigListen starts background long-poll for a config.
|
||||
@@ -77,10 +81,29 @@ func (a *App) NacosStartConfigListen(config connection.ConnectionConfig, payload
|
||||
contentMD5 = ""
|
||||
}
|
||||
|
||||
nacosListenMu.Lock()
|
||||
if nacosListenClosingCount > 0 {
|
||||
nacosListenMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: errNacosCacheInvalidated.Error()}
|
||||
}
|
||||
startGeneration := nacosListenGeneration
|
||||
startGenerationCtx := nacosListenGenerationCtx
|
||||
afterConnectHook := nacosListenStartAfterConnectHook
|
||||
nacosListenMu.Unlock()
|
||||
|
||||
// Ensure client can connect before starting loop.
|
||||
if _, err := a.getNacosClient(config); err != nil {
|
||||
connectCtx, cancelConnect := context.WithTimeout(
|
||||
startGenerationCtx,
|
||||
time.Duration(nacosOperationTimeoutSeconds(config))*time.Second,
|
||||
)
|
||||
_, err := a.getNacosClientWithContext(connectCtx, config)
|
||||
cancelConnect()
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if afterConnectHook != nil {
|
||||
afterConnectHook()
|
||||
}
|
||||
|
||||
watchID := strings.TrimSpace(payload.WatchID)
|
||||
if watchID == "" {
|
||||
@@ -88,6 +111,10 @@ func (a *App) NacosStartConfigListen(config connection.ConnectionConfig, payload
|
||||
}
|
||||
|
||||
nacosListenMu.Lock()
|
||||
if nacosListenClosingCount > 0 || nacosListenGeneration != startGeneration {
|
||||
nacosListenMu.Unlock()
|
||||
return connection.QueryResult{Success: false, Message: errNacosCacheInvalidated.Error()}
|
||||
}
|
||||
if existing, ok := nacosListenSessions[watchID]; ok && existing != nil {
|
||||
existing.cancel()
|
||||
delete(nacosListenSessions, watchID)
|
||||
@@ -172,7 +199,7 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
client, err := a.getNacosClient(config)
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
logger.Warnf("Nacos 监听获取连接失败,稍后重试:watchId=%s err=%v", session.watchID, err)
|
||||
if !sleepWithContext(ctx, nacosListenRestartBackoff) {
|
||||
@@ -205,7 +232,8 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi
|
||||
continue
|
||||
}
|
||||
|
||||
// Emit change event for matching target.
|
||||
// This watch is one-shot: after the matching target changes, the
|
||||
// frontend reloads the config and starts a fresh watch with its new MD5.
|
||||
for _, item := range changed {
|
||||
if !nacosListenTargetMatch(session, item) {
|
||||
continue
|
||||
@@ -218,9 +246,6 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi
|
||||
Group: session.group,
|
||||
ChangedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
// Small pause to avoid tight loop if client keeps reporting change before MD5 update.
|
||||
if !sleepWithContext(ctx, 300*time.Millisecond) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -280,9 +305,12 @@ func newNacosWatchID() string {
|
||||
return hex.EncodeToString(buf[:]) + hex.EncodeToString([]byte(time.Now().Format("150405")))
|
||||
}
|
||||
|
||||
// CloseAllNacosListeners stops all config listeners.
|
||||
func CloseAllNacosListeners() {
|
||||
func beginNacosListenerClose() []*nacosListenSession {
|
||||
nacosListenMu.Lock()
|
||||
previousGenerationCancel := nacosListenGenerationCancel
|
||||
nacosListenGeneration++
|
||||
nacosListenGenerationCtx, nacosListenGenerationCancel = context.WithCancel(context.Background())
|
||||
nacosListenClosingCount++
|
||||
sessions := make([]*nacosListenSession, 0, len(nacosListenSessions))
|
||||
for _, session := range nacosListenSessions {
|
||||
sessions = append(sessions, session)
|
||||
@@ -290,6 +318,21 @@ func CloseAllNacosListeners() {
|
||||
nacosListenSessions = make(map[string]*nacosListenSession)
|
||||
nacosListenMu.Unlock()
|
||||
|
||||
if previousGenerationCancel != nil {
|
||||
previousGenerationCancel()
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func finishNacosListenerClose() {
|
||||
nacosListenMu.Lock()
|
||||
if nacosListenClosingCount > 0 {
|
||||
nacosListenClosingCount--
|
||||
}
|
||||
nacosListenMu.Unlock()
|
||||
}
|
||||
|
||||
func cancelNacosListenSessions(sessions []*nacosListenSession) {
|
||||
for _, session := range sessions {
|
||||
if session != nil && session.cancel != nil {
|
||||
session.cancel()
|
||||
@@ -297,20 +340,38 @@ func CloseAllNacosListeners() {
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAllNacosListeners stops all config listeners.
|
||||
func CloseAllNacosListeners() {
|
||||
sessions := beginNacosListenerClose()
|
||||
defer finishNacosListenerClose()
|
||||
cancelNacosListenSessions(sessions)
|
||||
}
|
||||
|
||||
// CloseAllNacosClients closes cached nacos clients and listeners.
|
||||
func CloseAllNacosClients() {
|
||||
CloseAllNacosListeners()
|
||||
sessions := beginNacosListenerClose()
|
||||
defer finishNacosListenerClose()
|
||||
cancelNacosListenSessions(sessions)
|
||||
|
||||
nacosCacheMu.Lock()
|
||||
defer nacosCacheMu.Unlock()
|
||||
for key, client := range nacosCache {
|
||||
previousGenerationCancel := nacosCacheGenerationCancel
|
||||
nacosCacheGeneration++
|
||||
nacosCacheGenerationCtx, nacosCacheGenerationCancel = context.WithCancel(context.Background())
|
||||
clients := nacosCache
|
||||
nacosCache = make(map[string]nacos.Client)
|
||||
nacosCacheMu.Unlock()
|
||||
if previousGenerationCancel != nil {
|
||||
previousGenerationCancel()
|
||||
}
|
||||
|
||||
closedClients := 0
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
if len(key) >= 12 {
|
||||
logger.Infof("已关闭 Nacos 连接:%s", key[:12])
|
||||
}
|
||||
closedClients++
|
||||
}
|
||||
}
|
||||
nacosCache = make(map[string]nacos.Client)
|
||||
nacosCacheConfigs = make(map[string]connection.ConnectionConfig)
|
||||
if closedClients > 0 {
|
||||
logger.Infof("已关闭 %d 个 Nacos 连接", closedClients)
|
||||
}
|
||||
}
|
||||
|
||||
367
internal/app/methods_nacos_listen_race_test.go
Normal file
367
internal/app/methods_nacos_listen_race_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/nacos"
|
||||
"GoNavi-Wails/internal/uievents"
|
||||
)
|
||||
|
||||
type nacosListenerBarrierTestClient struct {
|
||||
nacos.Client
|
||||
listenStarted chan struct{}
|
||||
listenOnce sync.Once
|
||||
closed atomic.Int32
|
||||
}
|
||||
|
||||
func (client *nacosListenerBarrierTestClient) Connect(connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosListenerBarrierTestClient) ListenOnce(
|
||||
ctx context.Context,
|
||||
_ []nacos.ConfigListenTarget,
|
||||
_ int,
|
||||
) ([]nacos.ConfigListenTarget, error) {
|
||||
client.listenOnce.Do(func() {
|
||||
close(client.listenStarted)
|
||||
})
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
func (client *nacosListenerBarrierTestClient) Close() error {
|
||||
client.closed.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
type nacosBlockingCloseTestClient struct {
|
||||
nacos.Client
|
||||
closeStarted chan struct{}
|
||||
releaseClose chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type nacosRepeatingChangedTestClient struct {
|
||||
nacos.Client
|
||||
firstListen chan struct{}
|
||||
secondListen chan struct{}
|
||||
firstOnce sync.Once
|
||||
secondOnce sync.Once
|
||||
listenCalls atomic.Int32
|
||||
}
|
||||
|
||||
type nacosNoopListenEventEmitter struct{}
|
||||
|
||||
func (nacosNoopListenEventEmitter) Emit(string, ...any) {}
|
||||
|
||||
func (client *nacosRepeatingChangedTestClient) Connect(connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosRepeatingChangedTestClient) ListenOnce(
|
||||
_ context.Context,
|
||||
targets []nacos.ConfigListenTarget,
|
||||
_ int,
|
||||
) ([]nacos.ConfigListenTarget, error) {
|
||||
call := client.listenCalls.Add(1)
|
||||
if call == 1 {
|
||||
client.firstOnce.Do(func() {
|
||||
close(client.firstListen)
|
||||
})
|
||||
} else {
|
||||
client.secondOnce.Do(func() {
|
||||
close(client.secondListen)
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func (client *nacosRepeatingChangedTestClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosBlockingCloseTestClient) Connect(connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosBlockingCloseTestClient) Close() error {
|
||||
client.closeOnce.Do(func() {
|
||||
close(client.closeStarted)
|
||||
})
|
||||
<-client.releaseClose
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNacosConfigListenStopsAfterMatchingChange(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
CloseAllNacosListeners()
|
||||
defer CloseAllNacosClients()
|
||||
|
||||
client := &nacosRepeatingChangedTestClient{
|
||||
firstListen: make(chan struct{}),
|
||||
secondListen: make(chan struct{}),
|
||||
}
|
||||
newNacosClientFunc = func() nacos.Client {
|
||||
return client
|
||||
}
|
||||
|
||||
const watchID = "listener-one-shot"
|
||||
app := &App{
|
||||
ctx: uievents.WithEmitter(context.Background(), nacosNoopListenEventEmitter{}),
|
||||
}
|
||||
result := app.NacosStartConfigListen(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "listener-one-shot.nacos.local",
|
||||
Port: 8848,
|
||||
Timeout: 1,
|
||||
}, NacosStartConfigListenPayload{
|
||||
WatchID: watchID,
|
||||
NamespaceID: "dev",
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
ContentMD5: "original-md5",
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("start Nacos listener: %s", result.Message)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-client.firstListen:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for first Nacos listen request")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-client.secondListen:
|
||||
t.Fatal("listener polled again after emitting a matching change")
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
nacosListenMu.Lock()
|
||||
_, exists := nacosListenSessions[watchID]
|
||||
nacosListenMu.Unlock()
|
||||
if !exists {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("one-shot listener session was not removed after a matching change")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := client.listenCalls.Load(); got != 1 {
|
||||
t.Fatalf("Nacos listen calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosStartConfigListenCannotRegisterAfterCloseAllReturns(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
CloseAllNacosListeners()
|
||||
defer CloseAllNacosClients()
|
||||
|
||||
startConnected := make(chan struct{})
|
||||
releaseRegistration := make(chan struct{})
|
||||
var startConnectedOnce sync.Once
|
||||
var releaseRegistrationOnce sync.Once
|
||||
releaseStartRegistration := func() {
|
||||
releaseRegistrationOnce.Do(func() {
|
||||
close(releaseRegistration)
|
||||
})
|
||||
}
|
||||
defer releaseStartRegistration()
|
||||
originalAfterConnectHook := nacosListenStartAfterConnectHook
|
||||
nacosListenStartAfterConnectHook = func() {
|
||||
startConnectedOnce.Do(func() {
|
||||
close(startConnected)
|
||||
})
|
||||
<-releaseRegistration
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
nacosListenStartAfterConnectHook = originalAfterConnectHook
|
||||
})
|
||||
|
||||
listenStarted := make(chan struct{})
|
||||
var factoryCalls atomic.Int32
|
||||
newNacosClientFunc = func() nacos.Client {
|
||||
if factoryCalls.Add(1) == 1 {
|
||||
return &nacosCacheTestClient{}
|
||||
}
|
||||
return &nacosListenerBarrierTestClient{
|
||||
listenStarted: listenStarted,
|
||||
}
|
||||
}
|
||||
|
||||
app := &App{}
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "listener-register-race.nacos.local",
|
||||
Port: 8848,
|
||||
Timeout: 1,
|
||||
}
|
||||
if _, err := app.getNacosClient(config); err != nil {
|
||||
t.Fatalf("prewarm Nacos client: %v", err)
|
||||
}
|
||||
|
||||
startDone := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
startDone <- app.NacosStartConfigListen(config, NacosStartConfigListenPayload{
|
||||
WatchID: "listener-register-race",
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-startConnected:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for listener Start to acquire its client")
|
||||
}
|
||||
|
||||
CloseAllNacosClients()
|
||||
if got := factoryCalls.Load(); got != 1 {
|
||||
t.Fatalf("client factory calls before releasing Start = %d, want 1", got)
|
||||
}
|
||||
|
||||
releaseStartRegistration()
|
||||
var result connection.QueryResult
|
||||
select {
|
||||
case result = <-startDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("listener Start did not return after CloseAll")
|
||||
}
|
||||
|
||||
if result.Success {
|
||||
select {
|
||||
case <-listenStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("stale listener registered but did not expose its cache rebuild")
|
||||
}
|
||||
}
|
||||
|
||||
nacosListenMu.Lock()
|
||||
sessionCount := len(nacosListenSessions)
|
||||
nacosListenMu.Unlock()
|
||||
nacosCacheMu.Lock()
|
||||
cachedClientCount := len(nacosCache)
|
||||
nacosCacheMu.Unlock()
|
||||
|
||||
if result.Success {
|
||||
t.Fatal("listener Start unexpectedly succeeded after CloseAll returned")
|
||||
}
|
||||
if sessionCount != 0 {
|
||||
t.Fatalf("listener sessions after CloseAll = %d, want 0", sessionCount)
|
||||
}
|
||||
if got := factoryCalls.Load(); got != 1 {
|
||||
t.Fatalf("client factory calls after stale Start = %d, want 1", got)
|
||||
}
|
||||
if cachedClientCount != 0 {
|
||||
t.Fatalf("Nacos cache entries after stale Start = %d, want 0", cachedClientCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosStartConfigListenDoesNotWaitForCloseAllAndCanStartAfterItReturns(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
CloseAllNacosListeners()
|
||||
defer CloseAllNacosClients()
|
||||
|
||||
closeStarted := make(chan struct{})
|
||||
releaseClose := make(chan struct{})
|
||||
var releaseCloseOnce sync.Once
|
||||
releaseClientClose := func() {
|
||||
releaseCloseOnce.Do(func() {
|
||||
close(releaseClose)
|
||||
})
|
||||
}
|
||||
defer releaseClientClose()
|
||||
listenStarted := make(chan struct{})
|
||||
var factoryCalls atomic.Int32
|
||||
newNacosClientFunc = func() nacos.Client {
|
||||
if factoryCalls.Add(1) == 1 {
|
||||
return &nacosBlockingCloseTestClient{
|
||||
closeStarted: closeStarted,
|
||||
releaseClose: releaseClose,
|
||||
}
|
||||
}
|
||||
return &nacosListenerBarrierTestClient{
|
||||
listenStarted: listenStarted,
|
||||
}
|
||||
}
|
||||
|
||||
app := &App{}
|
||||
config := connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "listener-close-barrier.nacos.local",
|
||||
Port: 8848,
|
||||
Timeout: 1,
|
||||
}
|
||||
if _, err := app.getNacosClient(config); err != nil {
|
||||
t.Fatalf("prewarm Nacos client: %v", err)
|
||||
}
|
||||
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
CloseAllNacosClients()
|
||||
close(closeDone)
|
||||
}()
|
||||
select {
|
||||
case <-closeStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for CloseAll client close")
|
||||
}
|
||||
|
||||
blockedStartDone := make(chan connection.QueryResult, 1)
|
||||
go func() {
|
||||
blockedStartDone <- app.NacosStartConfigListen(config, NacosStartConfigListenPayload{
|
||||
WatchID: "listener-during-close",
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case result := <-blockedStartDone:
|
||||
if result.Success {
|
||||
t.Fatal("listener Start unexpectedly succeeded while CloseAll was active")
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
t.Fatal("listener Start waited for CloseAll instead of failing promptly")
|
||||
}
|
||||
if got := factoryCalls.Load(); got != 1 {
|
||||
t.Fatalf("client factory calls while CloseAll was active = %d, want 1", got)
|
||||
}
|
||||
|
||||
releaseClientClose()
|
||||
select {
|
||||
case <-closeDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("CloseAll did not return after client close was released")
|
||||
}
|
||||
|
||||
freshResult := app.NacosStartConfigListen(config, NacosStartConfigListenPayload{
|
||||
WatchID: "listener-after-close",
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
})
|
||||
if !freshResult.Success {
|
||||
t.Fatalf("fresh listener Start after CloseAll failed: %s", freshResult.Message)
|
||||
}
|
||||
stopResult := app.NacosStopConfigListen("listener-after-close")
|
||||
if !stopResult.Success {
|
||||
t.Fatalf("fresh listener Stop after CloseAll failed: %s", stopResult.Message)
|
||||
}
|
||||
nacosListenMu.Lock()
|
||||
sessionCount := len(nacosListenSessions)
|
||||
nacosListenMu.Unlock()
|
||||
if sessionCount != 0 {
|
||||
t.Fatalf("listener sessions after fresh Stop = %d, want 0", sessionCount)
|
||||
}
|
||||
if got := factoryCalls.Load(); got != 2 {
|
||||
t.Fatalf("client factory calls after fresh Start = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
93
internal/app/methods_nacos_namespace_test.go
Normal file
93
internal/app/methods_nacos_namespace_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/nacos"
|
||||
)
|
||||
|
||||
func TestNacosListNamespacesReturnsStableForbiddenCodeOnlyForForbidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantErrorCode bool
|
||||
}{
|
||||
{name: "forbidden", status: http.StatusForbidden, wantErrorCode: true},
|
||||
{name: "unauthorized", status: http.StatusUnauthorized},
|
||||
{name: "server error", status: http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
newNacosClientFunc = nacos.NewClient
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch request.URL.Path {
|
||||
case "/v3/admin/core/state/readiness":
|
||||
_, _ = fmt.Fprint(w, `{"code":0,"message":"success","data":"ok"}`)
|
||||
case "/v3/admin/core/namespace/list":
|
||||
w.WriteHeader(test.status)
|
||||
_, _ = fmt.Fprintf(w, `{"code":%d,"message":"denied"}`, test.status)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(serverURL.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("split server address: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server port: %v", err)
|
||||
}
|
||||
|
||||
result := (&App{}).NacosListNamespaces(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: host,
|
||||
Port: port,
|
||||
ConnectionParams: "contextPath=/",
|
||||
Timeout: 2,
|
||||
})
|
||||
if result.Success {
|
||||
t.Fatal("NacosListNamespaces unexpectedly succeeded")
|
||||
}
|
||||
if !strings.Contains(result.Message, strconv.Itoa(test.status)) {
|
||||
t.Fatalf("message = %q, want HTTP status %d", result.Message, test.status)
|
||||
}
|
||||
|
||||
data, hasData := result.Data.(map[string]any)
|
||||
if test.wantErrorCode {
|
||||
if !hasData {
|
||||
t.Fatalf("data = %#v, want stable forbidden error code", result.Data)
|
||||
}
|
||||
if got := data["errorCode"]; got != nacosNamespaceListForbiddenErrorCode {
|
||||
t.Fatalf("errorCode = %#v, want %q", got, nacosNamespaceListForbiddenErrorCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
if hasData {
|
||||
if _, exists := data["errorCode"]; exists {
|
||||
t.Fatalf("non-forbidden status exposed fallback error code: %#v", data)
|
||||
}
|
||||
} else if result.Data != nil {
|
||||
t.Fatalf("non-forbidden data = %#v, want nil", result.Data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type nacosImportPreviewItem struct {
|
||||
Index int `json:"index"`
|
||||
DataID string `json:"dataId"`
|
||||
Group string `json:"group"`
|
||||
Type string `json:"type,omitempty"`
|
||||
@@ -25,6 +27,11 @@ type nacosImportPreviewItem struct {
|
||||
Selected bool `json:"selected"`
|
||||
}
|
||||
|
||||
type nacosConfigIdentityKey struct {
|
||||
group string
|
||||
dataID string
|
||||
}
|
||||
|
||||
type nacosImportPreview struct {
|
||||
File string `json:"file"`
|
||||
ExportedAt string `json:"exportedAt,omitempty"`
|
||||
@@ -39,11 +46,6 @@ type nacosImportPreview struct {
|
||||
// NacosExportConfigs exports configs from a namespace to a JSON file.
|
||||
func (a *App) NacosExportConfigs(config connection.ConnectionConfig, options NacosExportConfigsOptions) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
client, err := a.getNacosClient(config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
scope := strings.ToLower(strings.TrimSpace(options.Scope))
|
||||
if scope == "" {
|
||||
scope = "all"
|
||||
@@ -79,6 +81,10 @@ func (a *App) NacosExportConfigs(config connection.ConnectionConfig, options Nac
|
||||
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
entries, err := collectNacosExportEntries(ctx, client, namespaceID, scope, options)
|
||||
if err != nil {
|
||||
@@ -127,24 +133,55 @@ func (a *App) NacosPreviewImportConfigs(config connection.ConnectionConfig, name
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
client, err := a.getNacosClient(config)
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
|
||||
preview := buildNacosImportPreview(ctx, client, selection, namespaceID, payload)
|
||||
preview, err := buildNacosImportPreview(ctx, client, selection, namespaceID, payload)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: preview}
|
||||
}
|
||||
|
||||
// NacosImportConfigs imports configs from a transfer file.
|
||||
func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options NacosImportConfigsOptions) connection.QueryResult {
|
||||
config.Type = "nacos"
|
||||
if err := a.ensureNacosDataEditAllowed(config); err != nil {
|
||||
if err := a.ensureNacosDataImportAllowed(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
scope := strings.ToLower(strings.TrimSpace(options.Scope))
|
||||
selectedByIndex := make(map[int]nacosConfigIdentityKey, len(options.Items))
|
||||
selectedByIdentity := make(map[nacosConfigIdentityKey]struct{}, len(options.Items))
|
||||
invalidIndexedSelection := false
|
||||
for _, item := range options.Items {
|
||||
key, ok := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if item.Index == nil {
|
||||
selectedByIdentity[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if *item.Index < 0 {
|
||||
continue
|
||||
}
|
||||
if existing, exists := selectedByIndex[*item.Index]; exists && existing != key {
|
||||
invalidIndexedSelection = true
|
||||
}
|
||||
selectedByIndex[*item.Index] = key
|
||||
}
|
||||
if scope == "selected" && len(selectedByIndex)+len(selectedByIdentity) == 0 {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("nacos.backend.error.import_selection_required", nil),
|
||||
}
|
||||
}
|
||||
|
||||
selection := strings.TrimSpace(options.File)
|
||||
var err error
|
||||
if selection == "" {
|
||||
@@ -162,25 +199,26 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
client, err := a.getNacosClient(config)
|
||||
useSelected := scope == "selected"
|
||||
if useSelected && (invalidIndexedSelection ||
|
||||
!nacosImportSelectionMatchesPayload(payload.Configs, selectedByIndex, selectedByIdentity)) {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("nacos.backend.error.import_selection_required", nil),
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
client, err := a.getNacosClientWithContext(ctx, config)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
ctx, cancel := a.nacosOperationContext(config)
|
||||
defer cancel()
|
||||
|
||||
conflictMode := strings.ToLower(strings.TrimSpace(options.ConflictMode))
|
||||
if conflictMode != "overwrite" {
|
||||
conflictMode = "skip"
|
||||
}
|
||||
selected := make(map[string]struct{}, len(options.Items))
|
||||
for _, item := range options.Items {
|
||||
key := nacosConfigKey(item.Group, item.DataID)
|
||||
if key != "" {
|
||||
selected[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
useSelected := strings.ToLower(strings.TrimSpace(options.Scope)) == "selected" && len(selected) > 0
|
||||
|
||||
imported := 0
|
||||
skipped := 0
|
||||
@@ -188,12 +226,11 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac
|
||||
var firstErr error
|
||||
namespaceID := strings.TrimSpace(options.NamespaceID)
|
||||
|
||||
for _, item := range payload.Configs {
|
||||
key := nacosConfigKey(item.Group, item.DataID)
|
||||
if useSelected {
|
||||
if _, ok := selected[key]; !ok {
|
||||
continue
|
||||
}
|
||||
for index, item := range payload.Configs {
|
||||
key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||||
if useSelected && (!validIdentity ||
|
||||
!nacosImportRowSelected(index, key, selectedByIndex, selectedByIdentity)) {
|
||||
continue
|
||||
}
|
||||
exists, existsErr := nacosConfigExists(ctx, client, namespaceID, item.Group, item.DataID)
|
||||
if existsErr != nil {
|
||||
@@ -225,19 +262,33 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac
|
||||
imported++
|
||||
}
|
||||
|
||||
if imported == 0 && failed > 0 && firstErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: firstErr.Error()}
|
||||
resultData := map[string]any{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"file": selection,
|
||||
}
|
||||
logger.Infof("Nacos 配置导入完成:imported=%d skipped=%d failed=%d file=%s", imported, skipped, failed, selection)
|
||||
if failed > 0 {
|
||||
detail := ""
|
||||
if firstErr != nil {
|
||||
detail = firstErr.Error()
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: a.appText("nacos.backend.error.import_partial_failed", map[string]any{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"detail": detail,
|
||||
}),
|
||||
Data: resultData,
|
||||
}
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: a.appText("nacos.backend.message.import_success", nil),
|
||||
Data: map[string]any{
|
||||
"imported": imported,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"file": selection,
|
||||
},
|
||||
Data: resultData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +345,7 @@ func collectNacosExportEntries(
|
||||
const pageSize = 100
|
||||
pageNo := 1
|
||||
entries := make([]nacos.TransferConfigEntry, 0, 64)
|
||||
seen := make(map[string]struct{})
|
||||
seen := make(map[nacosConfigIdentityKey]struct{})
|
||||
for {
|
||||
page, err := client.SearchConfigs(ctx, nacos.ConfigQuery{
|
||||
NamespaceID: namespaceID,
|
||||
@@ -309,7 +360,10 @@ func collectNacosExportEntries(
|
||||
break
|
||||
}
|
||||
for _, item := range page.PageItems {
|
||||
key := nacosConfigKey(item.Group, item.DataID)
|
||||
key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID)
|
||||
if !validIdentity {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
@@ -361,15 +415,19 @@ func buildNacosImportPreview(
|
||||
client nacos.Client,
|
||||
file, namespaceID string,
|
||||
payload nacos.TransferFile,
|
||||
) nacosImportPreview {
|
||||
) (nacosImportPreview, error) {
|
||||
items := make([]nacosImportPreviewItem, 0, len(payload.Configs))
|
||||
existsCount := 0
|
||||
for _, cfg := range payload.Configs {
|
||||
exists, _ := nacosConfigExists(ctx, client, namespaceID, cfg.Group, cfg.DataID)
|
||||
for index, cfg := range payload.Configs {
|
||||
exists, err := nacosConfigExists(ctx, client, namespaceID, cfg.Group, cfg.DataID)
|
||||
if err != nil {
|
||||
return nacosImportPreview{}, err
|
||||
}
|
||||
if exists {
|
||||
existsCount++
|
||||
}
|
||||
items = append(items, nacosImportPreviewItem{
|
||||
Index: index,
|
||||
DataID: cfg.DataID,
|
||||
Group: cfg.Group,
|
||||
Type: cfg.Type,
|
||||
@@ -386,7 +444,7 @@ func buildNacosImportPreview(
|
||||
ExistsCount: existsCount,
|
||||
NewCount: len(items) - existsCount,
|
||||
Items: items,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func nacosConfigExists(ctx context.Context, client nacos.Client, namespaceID, group, dataID string) (bool, error) {
|
||||
@@ -397,28 +455,143 @@ func nacosConfigExists(ctx context.Context, client nacos.Client, namespaceID, gr
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
// Treat not-found style errors as missing.
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") {
|
||||
if nacos.IsConfigNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
// Some servers return 404 wrapped as http_status.
|
||||
if strings.Contains(msg, "404") {
|
||||
return false, nil
|
||||
if status, ok := nacos.HTTPStatusCode(err); ok {
|
||||
if status == 404 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
// Compatibility fallback for errors produced before structured status and
|
||||
// not-found types were introduced. Match complete localized prefixes only.
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if status, ok := explicitNacosHTTPStatus(msg); ok {
|
||||
if status == 404 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"config not found:",
|
||||
"配置不存在:",
|
||||
"設定不存在:",
|
||||
"設定が見つかりません:",
|
||||
"konfiguration nicht gefunden:",
|
||||
"конфигурация не найдена:",
|
||||
} {
|
||||
if strings.HasPrefix(msg, prefix) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func nacosConfigKey(group, dataID string) string {
|
||||
func explicitNacosHTTPStatus(message string) (int, bool) {
|
||||
markers := []string{
|
||||
"nacos http",
|
||||
"nacos-http-fehler",
|
||||
"http nacos",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
index := strings.Index(message, marker)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
rest := message[index+len(marker):]
|
||||
start := -1
|
||||
for index, char := range rest {
|
||||
if char >= '0' && char <= '9' {
|
||||
start = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, false
|
||||
}
|
||||
end := start
|
||||
for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
if end-start != 3 {
|
||||
return 0, false
|
||||
}
|
||||
status, err := strconv.Atoi(rest[start:end])
|
||||
if err != nil || status < 100 || status > 599 {
|
||||
return 0, false
|
||||
}
|
||||
return status, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func normalizeNacosConfigIdentityKey(group, dataID string) (nacosConfigIdentityKey, bool) {
|
||||
dataID = strings.TrimSpace(dataID)
|
||||
if dataID == "" {
|
||||
return ""
|
||||
return nacosConfigIdentityKey{}, false
|
||||
}
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
group = "DEFAULT_GROUP"
|
||||
}
|
||||
return group + "@@" + dataID
|
||||
return nacosConfigIdentityKey{group: group, dataID: dataID}, true
|
||||
}
|
||||
|
||||
func nacosImportSelectionMatchesPayload(
|
||||
configs []nacos.TransferConfigEntry,
|
||||
selectedByIndex map[int]nacosConfigIdentityKey,
|
||||
selectedByIdentity map[nacosConfigIdentityKey]struct{},
|
||||
) bool {
|
||||
for index, selectedKey := range selectedByIndex {
|
||||
if index < 0 || index >= len(configs) {
|
||||
return false
|
||||
}
|
||||
payloadKey, ok := normalizeNacosConfigIdentityKey(configs[index].Group, configs[index].DataID)
|
||||
if !ok || payloadKey != selectedKey {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for selectedKey := range selectedByIdentity {
|
||||
matched := false
|
||||
for _, config := range configs {
|
||||
payloadKey, ok := normalizeNacosConfigIdentityKey(config.Group, config.DataID)
|
||||
if ok && payloadKey == selectedKey {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func nacosImportRowSelected(
|
||||
index int,
|
||||
key nacosConfigIdentityKey,
|
||||
selectedByIndex map[int]nacosConfigIdentityKey,
|
||||
selectedByIdentity map[nacosConfigIdentityKey]struct{},
|
||||
) bool {
|
||||
if selectedKey, ok := selectedByIndex[index]; ok && selectedKey == key {
|
||||
return true
|
||||
}
|
||||
_, ok := selectedByIdentity[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (a *App) ensureNacosDataImportAllowed(config connection.ConnectionConfig) error {
|
||||
if config.ReadOnly {
|
||||
return errors.New(a.appText("nacos.backend.error.read_only", nil))
|
||||
}
|
||||
if config.Protection.RestrictDataImport {
|
||||
return errors.New(readOnlyConnectionActionBlockedMessageWithText(
|
||||
"connection.backend.action.import_data",
|
||||
a.appText,
|
||||
))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeNacosTransferFilename(filename string) string {
|
||||
|
||||
421
internal/app/methods_nacos_transfer_test.go
Normal file
421
internal/app/methods_nacos_transfer_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/nacos"
|
||||
)
|
||||
|
||||
type nacosTransferTestClient struct {
|
||||
nacos.Client
|
||||
getConfig func(context.Context, string, string, string) (*nacos.ConfigDetail, error)
|
||||
publish func(context.Context, nacos.PublishRequest) error
|
||||
closed atomic.Int32
|
||||
}
|
||||
|
||||
func (client *nacosTransferTestClient) Connect(connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosTransferTestClient) Close() error {
|
||||
client.closed.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *nacosTransferTestClient) GetConfig(
|
||||
ctx context.Context,
|
||||
namespaceID, group, dataID string,
|
||||
) (*nacos.ConfigDetail, error) {
|
||||
if client.getConfig != nil {
|
||||
return client.getConfig(ctx, namespaceID, group, dataID)
|
||||
}
|
||||
return nil, errors.New("Config not found: test fixture")
|
||||
}
|
||||
|
||||
func (client *nacosTransferTestClient) PublishConfig(ctx context.Context, request nacos.PublishRequest) error {
|
||||
if client.publish != nil {
|
||||
return client.publish(ctx, request)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNacosImportConfigsRejectsEmptyEffectiveSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []NacosConfigIdentity
|
||||
}{
|
||||
{name: "empty", items: nil},
|
||||
{
|
||||
name: "all invalid",
|
||||
items: []NacosConfigIdentity{
|
||||
{DataID: " ", Group: "DEFAULT_GROUP"},
|
||||
{DataID: "", Group: "DEV_GROUP"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
var published atomic.Int32
|
||||
client := &nacosTransferTestClient{
|
||||
publish: func(context.Context, nacos.PublishRequest) error {
|
||||
published.Add(1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
newNacosClientFunc = func() nacos.Client { return client }
|
||||
|
||||
transfer := nacos.NewTransferFile("dev", "Development")
|
||||
transfer.Configs = []nacos.TransferConfigEntry{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
Content: "enabled: true",
|
||||
}}
|
||||
filename := filepath.Join(t.TempDir(), "selected-import.json")
|
||||
if err := nacos.WriteTransferFile(filename, transfer); err != nil {
|
||||
t.Fatalf("WriteTransferFile: %v", err)
|
||||
}
|
||||
|
||||
result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.example.test",
|
||||
Port: 8848,
|
||||
Timeout: 1,
|
||||
}, NacosImportConfigsOptions{
|
||||
NamespaceID: "dev",
|
||||
ConflictMode: "overwrite",
|
||||
File: filename,
|
||||
Scope: "selected",
|
||||
Items: test.items,
|
||||
})
|
||||
if result.Success {
|
||||
t.Fatalf("selected import unexpectedly succeeded: %#v", result)
|
||||
}
|
||||
if got := published.Load(); got != 0 {
|
||||
t.Fatalf("selected import published %d config(s), want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosImportConfigsMatchesSelectedIdentityWithoutDelimiterCollisions(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
|
||||
var published []nacos.PublishRequest
|
||||
client := &nacosTransferTestClient{
|
||||
publish: func(_ context.Context, request nacos.PublishRequest) error {
|
||||
published = append(published, request)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
newNacosClientFunc = func() nacos.Client { return client }
|
||||
|
||||
transfer := nacos.NewTransferFile("dev", "Development")
|
||||
transfer.Configs = []nacos.TransferConfigEntry{
|
||||
{Group: "A", DataID: "B@@C", Content: "first"},
|
||||
{Group: "A@@B", DataID: "C", Content: "second"},
|
||||
}
|
||||
filename := filepath.Join(t.TempDir(), "delimiter-selection.json")
|
||||
if err := nacos.WriteTransferFile(filename, transfer); err != nil {
|
||||
t.Fatalf("WriteTransferFile: %v", err)
|
||||
}
|
||||
selectedIndex := 1
|
||||
|
||||
result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.example.test",
|
||||
Port: 8848,
|
||||
}, NacosImportConfigsOptions{
|
||||
NamespaceID: "dev",
|
||||
ConflictMode: "overwrite",
|
||||
File: filename,
|
||||
Scope: "selected",
|
||||
Items: []NacosConfigIdentity{{
|
||||
Index: &selectedIndex,
|
||||
Group: "A@@B",
|
||||
DataID: "C",
|
||||
}},
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("selected import failed: %#v", result)
|
||||
}
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published %d configs, want exactly 1", len(published))
|
||||
}
|
||||
if published[0].Group != "A@@B" || published[0].DataID != "C" || published[0].Content != "second" {
|
||||
t.Fatalf("published %#v, want the second preview row", published[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosImportConfigsRejectsSelectedItemsThatDoNotMatchTheFile(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
|
||||
var published atomic.Int32
|
||||
client := &nacosTransferTestClient{
|
||||
publish: func(context.Context, nacos.PublishRequest) error {
|
||||
published.Add(1)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
newNacosClientFunc = func() nacos.Client { return client }
|
||||
|
||||
transfer := nacos.NewTransferFile("dev", "Development")
|
||||
transfer.Configs = []nacos.TransferConfigEntry{{
|
||||
Group: "DEFAULT_GROUP", DataID: "application.yaml", Content: "enabled: true",
|
||||
}}
|
||||
filename := filepath.Join(t.TempDir(), "selection-mismatch.json")
|
||||
if err := nacos.WriteTransferFile(filename, transfer); err != nil {
|
||||
t.Fatalf("WriteTransferFile: %v", err)
|
||||
}
|
||||
|
||||
result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.example.test",
|
||||
Port: 8848,
|
||||
}, NacosImportConfigsOptions{
|
||||
NamespaceID: "dev",
|
||||
File: filename,
|
||||
Scope: "selected",
|
||||
Items: []NacosConfigIdentity{{
|
||||
Group: "DEFAULT_GROUP", DataID: "missing.yaml",
|
||||
}},
|
||||
})
|
||||
if result.Success {
|
||||
t.Fatalf("mismatched selected import unexpectedly succeeded: %#v", result)
|
||||
}
|
||||
if got := published.Load(); got != 0 {
|
||||
t.Fatalf("mismatched selected import published %d config(s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosImportConfigsReportsPartialFailures(t *testing.T) {
|
||||
installNacosCacheTestHooks(t)
|
||||
|
||||
client := &nacosTransferTestClient{
|
||||
publish: func(_ context.Context, request nacos.PublishRequest) error {
|
||||
if request.DataID == "failed.yaml" {
|
||||
return errors.New("publish denied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
newNacosClientFunc = func() nacos.Client { return client }
|
||||
|
||||
transfer := nacos.NewTransferFile("dev", "Development")
|
||||
transfer.Configs = []nacos.TransferConfigEntry{
|
||||
{Group: "DEFAULT_GROUP", DataID: "ok.yaml", Content: "ok"},
|
||||
{Group: "DEFAULT_GROUP", DataID: "failed.yaml", Content: "failed"},
|
||||
}
|
||||
filename := filepath.Join(t.TempDir(), "partial-failure.json")
|
||||
if err := nacos.WriteTransferFile(filename, transfer); err != nil {
|
||||
t.Fatalf("WriteTransferFile: %v", err)
|
||||
}
|
||||
|
||||
result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.example.test",
|
||||
Port: 8848,
|
||||
}, NacosImportConfigsOptions{
|
||||
NamespaceID: "dev",
|
||||
ConflictMode: "overwrite",
|
||||
File: filename,
|
||||
Scope: "all",
|
||||
})
|
||||
if result.Success {
|
||||
t.Fatalf("partial import unexpectedly reported success: %#v", result)
|
||||
}
|
||||
counts, ok := result.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("partial import data = %#v, want count map", result.Data)
|
||||
}
|
||||
if counts["imported"] != 1 || counts["failed"] != 1 {
|
||||
t.Fatalf("partial import counts = %#v, want imported=1 failed=1", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNacosImportPreviewFailsWhenExistenceCheckIsForbidden(t *testing.T) {
|
||||
messages := []string{
|
||||
"Nacos HTTP error 403: permission denied; config not found",
|
||||
"Nacos HTTP 错误 403:权限不足;配置不存在",
|
||||
"Nacos HTTP 錯誤 403:權限不足;設定不存在",
|
||||
"Nacos HTTP エラー 403: 権限がありません; 設定が見つかりません",
|
||||
"Nacos-HTTP-Fehler 403: Keine Berechtigung; Konfiguration nicht gefunden",
|
||||
"Ошибка HTTP Nacos 403: нет разрешения; Конфигурация не найдена",
|
||||
}
|
||||
for _, forbiddenMessage := range messages {
|
||||
t.Run(forbiddenMessage, func(t *testing.T) {
|
||||
client := &nacosTransferTestClient{
|
||||
getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) {
|
||||
return nil, errors.New(forbiddenMessage)
|
||||
},
|
||||
}
|
||||
payload := nacos.NewTransferFile("source", "Source")
|
||||
payload.Configs = []nacos.TransferConfigEntry{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
}}
|
||||
|
||||
_, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload)
|
||||
if err == nil {
|
||||
t.Fatal("preview unexpectedly treated forbidden existence check as missing config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("preview error = %v, want forbidden existence check error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNacosImportPreviewFailsOnNonNotFoundErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{name: "timeout", err: context.DeadlineExceeded},
|
||||
{name: "unrelated number", err: errors.New("upstream port 4040 is unavailable")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &nacosTransferTestClient{
|
||||
getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) {
|
||||
return nil, test.err
|
||||
},
|
||||
}
|
||||
payload := nacos.NewTransferFile("source", "Source")
|
||||
payload.Configs = []nacos.TransferConfigEntry{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
}}
|
||||
|
||||
_, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload)
|
||||
if err == nil {
|
||||
t.Fatalf("preview unexpectedly treated %v as missing config", test.err)
|
||||
}
|
||||
if !errors.Is(err, test.err) && err.Error() != test.err.Error() {
|
||||
t.Fatalf("preview error = %v, want %v", err, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNacosImportPreviewTreatsExplicitHTTP404AsMissing(t *testing.T) {
|
||||
client := &nacosTransferTestClient{
|
||||
getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) {
|
||||
return nil, errors.New("Nacos HTTP error 404: config is absent")
|
||||
},
|
||||
}
|
||||
payload := nacos.NewTransferFile("source", "Source")
|
||||
payload.Configs = []nacos.TransferConfigEntry{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
}}
|
||||
|
||||
preview, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("preview rejected an explicit HTTP 404: %v", err)
|
||||
}
|
||||
if preview.ExistsCount != 0 || preview.NewCount != 1 || len(preview.Items) != 1 ||
|
||||
preview.Items[0].Exists || preview.Items[0].Index != 0 {
|
||||
t.Fatalf("preview = %#v, want one missing config", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNacosImportPreviewTreatsLocalizedConfigNotFoundAsMissing(t *testing.T) {
|
||||
messages := []string{
|
||||
"Config not found: DEFAULT_GROUP / application.yaml",
|
||||
"配置不存在:DEFAULT_GROUP / application.yaml",
|
||||
"設定不存在:DEFAULT_GROUP / application.yaml",
|
||||
"設定が見つかりません: DEFAULT_GROUP / application.yaml",
|
||||
"Konfiguration nicht gefunden: DEFAULT_GROUP / application.yaml",
|
||||
"Конфигурация не найдена: DEFAULT_GROUP / application.yaml",
|
||||
}
|
||||
for _, message := range messages {
|
||||
t.Run(message, func(t *testing.T) {
|
||||
client := &nacosTransferTestClient{
|
||||
getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) {
|
||||
return nil, errors.New(message)
|
||||
},
|
||||
}
|
||||
payload := nacos.NewTransferFile("source", "Source")
|
||||
payload.Configs = []nacos.TransferConfigEntry{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
}}
|
||||
|
||||
preview, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload)
|
||||
if err != nil {
|
||||
t.Fatalf("preview rejected localized config-not-found error: %v", err)
|
||||
}
|
||||
if preview.NewCount != 1 || preview.ExistsCount != 0 {
|
||||
t.Fatalf("preview = %#v, want one missing config", preview)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNacosDataImportAllowedHonorsExplicitProtection(t *testing.T) {
|
||||
app := &App{}
|
||||
base := connection.ConnectionConfig{Type: "nacos"}
|
||||
|
||||
if err := app.ensureNacosDataImportAllowed(base); err != nil {
|
||||
t.Fatalf("unrestricted import was rejected: %v", err)
|
||||
}
|
||||
|
||||
importRestricted := base
|
||||
importRestricted.Protection.RestrictDataImport = true
|
||||
if err := app.ensureNacosDataImportAllowed(importRestricted); err == nil {
|
||||
t.Fatal("restrictDataImport should reject Nacos config import")
|
||||
}
|
||||
|
||||
dataEditRestricted := base
|
||||
dataEditRestricted.Protection.RestrictDataEdit = true
|
||||
if err := app.ensureNacosDataImportAllowed(dataEditRestricted); err != nil {
|
||||
t.Fatalf("restrictDataEdit should not reject independently protected Nacos config import: %v", err)
|
||||
}
|
||||
|
||||
readOnly := base
|
||||
readOnly.ReadOnly = true
|
||||
if err := app.ensureNacosDataImportAllowed(readOnly); err == nil {
|
||||
t.Fatal("readOnly should reject Nacos config import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNacosStructureEditAllowedHonorsOwnProtection(t *testing.T) {
|
||||
app := &App{}
|
||||
base := connection.ConnectionConfig{Type: "nacos"}
|
||||
|
||||
if err := app.ensureNacosStructureEditAllowed(base); err != nil {
|
||||
t.Fatalf("unrestricted structure edit was rejected: %v", err)
|
||||
}
|
||||
|
||||
structureRestricted := base
|
||||
structureRestricted.Protection.RestrictStructureEdit = true
|
||||
if err := app.ensureNacosStructureEditAllowed(structureRestricted); err == nil {
|
||||
t.Fatal("restrictStructureEdit should reject Nacos structure edits")
|
||||
}
|
||||
|
||||
dataEditRestricted := base
|
||||
dataEditRestricted.Protection.RestrictDataEdit = true
|
||||
if err := app.ensureNacosStructureEditAllowed(dataEditRestricted); err != nil {
|
||||
t.Fatalf("restrictDataEdit should not reject independently protected Nacos structure edits: %v", err)
|
||||
}
|
||||
|
||||
importRestricted := base
|
||||
importRestricted.Protection.RestrictDataImport = true
|
||||
if err := app.ensureNacosStructureEditAllowed(importRestricted); err != nil {
|
||||
t.Fatalf("restrictDataImport should not reject independently protected Nacos structure edits: %v", err)
|
||||
}
|
||||
|
||||
readOnly := base
|
||||
readOnly.ReadOnly = true
|
||||
if err := app.ensureNacosStructureEditAllowed(readOnly); err == nil {
|
||||
t.Fatal("readOnly should reject Nacos structure edits")
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,59 @@ package nacos
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
nacosV3ReadinessPath = "/v3/admin/core/state/readiness"
|
||||
nacosV2ReadinessPath = "/v2/console/health/readiness"
|
||||
nacosV1ReadinessPath = "/v1/console/health/readiness"
|
||||
)
|
||||
|
||||
type nacosAPIFamily uint8
|
||||
|
||||
type nacosHTTPError struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
type nacosConfigNotFoundError struct {
|
||||
group string
|
||||
dataID string
|
||||
}
|
||||
|
||||
func (e *nacosHTTPError) Error() string {
|
||||
return localizedNacosBackendText("nacos.backend.error.http_status", map[string]any{
|
||||
"status": e.status,
|
||||
"body": e.body,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *nacosConfigNotFoundError) Error() string {
|
||||
return localizedNacosBackendText("nacos.backend.error.config_not_found", map[string]any{
|
||||
"group": e.group,
|
||||
"dataId": e.dataID,
|
||||
})
|
||||
}
|
||||
|
||||
// HTTPStatusCode extracts a Nacos HTTP status from an error.
|
||||
func HTTPStatusCode(err error) (int, bool) {
|
||||
var statusErr *nacosHTTPError
|
||||
if !errors.As(err, &statusErr) {
|
||||
return 0, false
|
||||
}
|
||||
return statusErr.status, true
|
||||
}
|
||||
|
||||
// IsConfigNotFound reports whether an error explicitly represents a missing
|
||||
// Nacos config. It intentionally does not classify arbitrary error text.
|
||||
func IsConfigNotFound(err error) bool {
|
||||
var notFoundErr *nacosConfigNotFoundError
|
||||
return errors.As(err, ¬FoundErr)
|
||||
}
|
||||
|
||||
const (
|
||||
nacosAPIUnknown nacosAPIFamily = iota
|
||||
nacosAPIV1
|
||||
@@ -101,21 +148,62 @@ func (c *ClientImpl) currentAPIRoutes() nacosAPIRoutes {
|
||||
|
||||
func (c *ClientImpl) detectAPIFamily(ctx context.Context) error {
|
||||
probes := []struct {
|
||||
family nacosAPIFamily
|
||||
path string
|
||||
family nacosAPIFamily
|
||||
path string
|
||||
readiness bool
|
||||
validate func([]byte) error
|
||||
}{
|
||||
{family: nacosAPIV3, path: routesForNacosAPI(nacosAPIV3).namespaceList},
|
||||
{family: nacosAPIV2, path: routesForNacosAPI(nacosAPIV2).namespaceList},
|
||||
{family: nacosAPIV1, path: routesForNacosAPI(nacosAPIV1).namespaceList},
|
||||
{
|
||||
family: nacosAPIV3,
|
||||
path: nacosV3ReadinessPath,
|
||||
readiness: true,
|
||||
validate: validateNacosAPIReadinessProbe,
|
||||
},
|
||||
{
|
||||
family: nacosAPIV2,
|
||||
path: nacosV2ReadinessPath,
|
||||
readiness: true,
|
||||
validate: validateNacosAPIReadinessProbe,
|
||||
},
|
||||
// Nacos 2.2 introduced the v2 APIs before the v2 readiness endpoint.
|
||||
// Its namespace list is unprotected and remains the compatibility probe.
|
||||
{
|
||||
family: nacosAPIV2,
|
||||
path: routesForNacosAPI(nacosAPIV2).namespaceList,
|
||||
validate: validateNacosAPIProbe,
|
||||
},
|
||||
{
|
||||
family: nacosAPIV1,
|
||||
path: nacosV1ReadinessPath,
|
||||
readiness: true,
|
||||
validate: validateNacosV1ReadinessProbe,
|
||||
},
|
||||
// Official Nacos 1.x has exposed readiness since 1.0.0. Keep the
|
||||
// namespace route as a final compatibility fallback for deployments
|
||||
// whose reverse proxy intentionally hides the health controller.
|
||||
{
|
||||
family: nacosAPIV1,
|
||||
path: routesForNacosAPI(nacosAPIV1).namespaceList,
|
||||
validate: validateNacosAPIProbe,
|
||||
},
|
||||
}
|
||||
|
||||
for _, probe := range probes {
|
||||
body, status, err := c.doRequest(ctx, http.MethodGet, probe.path, nil, nil)
|
||||
var (
|
||||
body []byte
|
||||
status int
|
||||
err error
|
||||
)
|
||||
if probe.readiness {
|
||||
body, status, err = c.doReadinessRequest(ctx, probe.path)
|
||||
} else {
|
||||
body, status, err = c.doRequest(ctx, http.MethodGet, probe.path, nil, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status >= 200 && status < 300 {
|
||||
if err := validateNacosAPIProbe(body); err != nil {
|
||||
if err := probe.validate(body); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
@@ -132,30 +220,105 @@ func (c *ClientImpl) detectAPIFamily(ctx context.Context) error {
|
||||
return nacosHTTPStatusError(http.StatusNotFound, []byte("no supported Nacos API family found"))
|
||||
}
|
||||
|
||||
func validateNacosAPIProbe(body []byte) error {
|
||||
var envelope struct {
|
||||
Code *int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
func (c *ClientImpl) probeReadiness(ctx context.Context, family nacosAPIFamily) error {
|
||||
path := nacosV1ReadinessPath
|
||||
validate := validateNacosV1ReadinessProbe
|
||||
switch family {
|
||||
case nacosAPIV3:
|
||||
path = nacosV3ReadinessPath
|
||||
validate = validateNacosAPIReadinessProbe
|
||||
case nacosAPIV2:
|
||||
path = nacosV2ReadinessPath
|
||||
validate = validateNacosAPIReadinessProbe
|
||||
}
|
||||
|
||||
body, status, err := c.doReadinessRequest(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if family != nacosAPIV3 && isMissingNacosAPI(status, body) {
|
||||
// Nacos 2.2 predates the v2 readiness controller. The same
|
||||
// fallback also preserves compatibility with Nacos 1.x
|
||||
// deployments whose reverse proxy hides the health endpoint.
|
||||
_, err = c.ListNamespaces(ctx)
|
||||
return err
|
||||
}
|
||||
return nacosHTTPStatusError(status, body)
|
||||
}
|
||||
return validate(body)
|
||||
}
|
||||
|
||||
func (c *ClientImpl) doReadinessRequest(ctx context.Context, path string) ([]byte, int, error) {
|
||||
body, status, err := c.doRequestRaw(ctx, http.MethodGet, path, nil, nil, false)
|
||||
if err != nil || (status != http.StatusUnauthorized && status != http.StatusForbidden) {
|
||||
return body, status, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
hasCredentials := strings.TrimSpace(c.config.User) != ""
|
||||
c.mu.Unlock()
|
||||
if !hasCredentials {
|
||||
return body, status, nil
|
||||
}
|
||||
return c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
}
|
||||
|
||||
type nacosAPIProbeEnvelope struct {
|
||||
Code *int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func parseNacosAPIProbe(body []byte) (nacosAPIProbeEnvelope, error) {
|
||||
var envelope nacosAPIProbeEnvelope
|
||||
if err := json.Unmarshal(body, &envelope); err != nil || envelope.Code == nil {
|
||||
detail := "response is not a Nacos API result"
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
}
|
||||
return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{
|
||||
return nacosAPIProbeEnvelope{}, localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
if *envelope.Code != 0 && *envelope.Code != 200 {
|
||||
return localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{
|
||||
return nacosAPIProbeEnvelope{}, localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{
|
||||
"code": *envelope.Code,
|
||||
"message": strings.TrimSpace(envelope.Message),
|
||||
"message": truncateForError(envelope.Message),
|
||||
})
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func validateNacosAPIProbe(body []byte) error {
|
||||
_, err := parseNacosAPIProbe(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateNacosAPIReadinessProbe(body []byte) error {
|
||||
envelope, err := parseNacosAPIProbe(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var readiness string
|
||||
if err := json.Unmarshal(envelope.Data, &readiness); err != nil ||
|
||||
!strings.EqualFold(strings.TrimSpace(readiness), "ok") {
|
||||
return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{
|
||||
"detail": "response is not a Nacos API readiness result",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNacosV1ReadinessProbe(body []byte) error {
|
||||
if strings.EqualFold(strings.TrimSpace(string(body)), "OK") {
|
||||
return nil
|
||||
}
|
||||
return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{
|
||||
"detail": "response is not a Nacos v1 readiness result",
|
||||
})
|
||||
}
|
||||
|
||||
func isMissingNacosAPI(status int, body []byte) bool {
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
|
||||
return true
|
||||
@@ -170,10 +333,10 @@ func isMissingNacosAPI(status int, body []byte) bool {
|
||||
}
|
||||
|
||||
func nacosHTTPStatusError(status int, body []byte) error {
|
||||
return localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
|
||||
"status": status,
|
||||
"body": truncateForError(string(body)),
|
||||
})
|
||||
return &nacosHTTPError{
|
||||
status: status,
|
||||
body: truncateForError(string(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// unwrapNacosResult extracts data from the Result<T> envelope used by Nacos
|
||||
@@ -190,7 +353,7 @@ func unwrapNacosResult(body []byte) ([]byte, error) {
|
||||
if *envelope.Code != 0 && *envelope.Code != 200 {
|
||||
return nil, localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{
|
||||
"code": *envelope.Code,
|
||||
"message": strings.TrimSpace(envelope.Message),
|
||||
"message": truncateForError(envelope.Message),
|
||||
})
|
||||
}
|
||||
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -134,7 +136,7 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
case nacosV3ReadinessPath:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, `{"code":403,"message":"no such api for this account"}`)
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList, routesForNacosAPI(nacosAPIV1).namespaceList:
|
||||
@@ -153,7 +155,7 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("Connect error = %q, want HTTP 403", err)
|
||||
}
|
||||
if got := recorder.countPath(routesForNacosAPI(nacosAPIV3).namespaceList); got == 0 {
|
||||
if got := recorder.countPath(nacosV3ReadinessPath); got == 0 {
|
||||
t.Fatal("v3 probe was not requested")
|
||||
}
|
||||
if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 0 {
|
||||
@@ -164,8 +166,214 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) {
|
||||
func TestClientAPIFamilyDetectionUsesV2ReadinessWithoutNamespacePermission(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case nacosV3ReadinessPath:
|
||||
http.NotFound(w, request)
|
||||
case nacosV2ReadinessPath:
|
||||
writeNacosResult(w, nacosAPIV2, "ok")
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
http.Error(w, "namespace permission denied", http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := connectAPIVersionTestClient(t, server)
|
||||
defer client.Close()
|
||||
if client.apiFamily != nacosAPIV2 {
|
||||
t.Fatalf("detected API family = %d, want v2", client.apiFamily)
|
||||
}
|
||||
if got := recorder.countPath(nacosV2ReadinessPath); got != 2 {
|
||||
t.Fatalf("v2 readiness probe count = %d, want 2 for detection and ping", got)
|
||||
}
|
||||
if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 0 {
|
||||
t.Fatalf("v2 namespace probe count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPIFamilyDetectionUsesV1ReadinessWithoutNamespacePermission(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case nacosV3ReadinessPath, nacosV2ReadinessPath, routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
http.NotFound(w, request)
|
||||
case nacosV1ReadinessPath:
|
||||
_, _ = io.WriteString(w, "OK")
|
||||
case routesForNacosAPI(nacosAPIV1).namespaceList:
|
||||
http.Error(w, "namespace permission denied", http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := connectAPIVersionTestClient(t, server)
|
||||
defer client.Close()
|
||||
if client.apiFamily != nacosAPIV1 {
|
||||
t.Fatalf("detected API family = %d, want v1", client.apiFamily)
|
||||
}
|
||||
if got := recorder.countPath(nacosV1ReadinessPath); got != 2 {
|
||||
t.Fatalf("v1 readiness probe count = %d, want 2 for detection and ping", got)
|
||||
}
|
||||
if got := recorder.countPath(routesForNacosAPI(nacosAPIV1).namespaceList); got != 0 {
|
||||
t.Fatalf("v1 namespace probe count = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAPIFamilyDetectionKeepsNacos22NamespaceFallback(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case nacosV3ReadinessPath, nacosV2ReadinessPath:
|
||||
http.NotFound(w, request)
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
writeNacosResult(w, nacosAPIV2, []any{})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := connectAPIVersionTestClient(t, server)
|
||||
defer client.Close()
|
||||
if client.apiFamily != nacosAPIV2 {
|
||||
t.Fatalf("detected API family = %d, want v2", client.apiFamily)
|
||||
}
|
||||
if got := recorder.countPath(nacosV2ReadinessPath); got != 2 {
|
||||
t.Fatalf("v2 readiness probe count = %d, want 2 for detection and ping", got)
|
||||
}
|
||||
if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 2 {
|
||||
t.Fatalf("v2 namespace probe count = %d, want 2 for detection and ping fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNacosV1ReadinessProbe(t *testing.T) {
|
||||
for _, body := range []string{"OK", " ok\r\n"} {
|
||||
if err := validateNacosV1ReadinessProbe([]byte(body)); err != nil {
|
||||
t.Fatalf("validate v1 readiness body %q: %v", body, err)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{"", "<html>console</html>", `{"code":200,"data":"OK"}`} {
|
||||
if err := validateNacosV1ReadinessProbe([]byte(body)); err == nil {
|
||||
t.Fatalf("validate v1 readiness body %q unexpectedly succeeded", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNacosAPIReadinessProbe(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"code":0,"message":"success","data":"ok"}`,
|
||||
`{"code":200,"message":"success","data":" OK "}`,
|
||||
} {
|
||||
if err := validateNacosAPIReadinessProbe([]byte(body)); err != nil {
|
||||
t.Fatalf("validate readiness body %q: %v", body, err)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{
|
||||
`{"code":0,"message":"success","data":true}`,
|
||||
`{"code":0,"message":"success","data":"ready"}`,
|
||||
`{"code":0,"message":"success","data":null}`,
|
||||
`{"code":0,"message":"success"}`,
|
||||
} {
|
||||
if err := validateNacosAPIReadinessProbe([]byte(body)); err == nil {
|
||||
t.Fatalf("validate readiness body %q unexpectedly succeeded", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReadinessRejectsNonOfficialSuccessPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
invalidOnCall int
|
||||
}{
|
||||
{name: "detection", invalidOnCall: 1},
|
||||
{name: "connect ping", invalidOnCall: 2},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var readinessRequests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case nacosV3ReadinessPath:
|
||||
requestNumber := int(readinessRequests.Add(1))
|
||||
data := any("ok")
|
||||
if requestNumber == test.invalidOnCall {
|
||||
data = true
|
||||
}
|
||||
writeNacosResult(w, nacosAPIV3, data)
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
writeNacosResult(w, nacosAPIV2, []any{})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &ClientImpl{}
|
||||
err := client.Connect(nacosAPITestConnectionConfig(t, server))
|
||||
if err == nil {
|
||||
_ = client.Close()
|
||||
t.Fatal("Connect unexpectedly accepted non-official readiness data")
|
||||
}
|
||||
if got := int(readinessRequests.Load()); got != test.invalidOnCall {
|
||||
t.Fatalf("readiness requests = %d, want %d", got, test.invalidOnCall)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPublicReadinessOmitsAccessToken(t *testing.T) {
|
||||
const accessToken = "public-readiness-token"
|
||||
var readinessMu sync.Mutex
|
||||
var readinessTokens []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
writeNacosJSON(w, map[string]any{"accessToken": accessToken, "tokenTtl": 3600})
|
||||
case nacosV3ReadinessPath:
|
||||
readinessMu.Lock()
|
||||
readinessTokens = append(readinessTokens, request.URL.Query().Get("accessToken"))
|
||||
readinessMu.Unlock()
|
||||
writeNacosResult(w, nacosAPIV3, "ok")
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := nacosAPITestConnectionConfig(t, server)
|
||||
config.User = "nacos"
|
||||
config.Password = "secret"
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
readinessMu.Lock()
|
||||
gotReadinessTokens := append([]string(nil), readinessTokens...)
|
||||
readinessMu.Unlock()
|
||||
if len(gotReadinessTokens) != 2 {
|
||||
t.Fatalf("readiness requests = %d, want 2", len(gotReadinessTokens))
|
||||
}
|
||||
for index, token := range gotReadinessTokens {
|
||||
if token != "" {
|
||||
t.Fatalf("readiness request %d sent accessToken %q", index+1, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReadinessRetriesWithTokenForAuthGatedProxy(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
var readinessMu sync.Mutex
|
||||
var readinessTokens []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
@@ -177,12 +385,16 @@ func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) {
|
||||
writeNacosJSON(w, map[string]any{"accessToken": "v3-token", "tokenTtl": 3600})
|
||||
case "/v1/auth/users/login":
|
||||
http.Error(w, "unexpected legacy login", http.StatusInternalServerError)
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
if request.URL.Query().Get("accessToken") != "v3-token" {
|
||||
case nacosV3ReadinessPath:
|
||||
token := request.URL.Query().Get("accessToken")
|
||||
readinessMu.Lock()
|
||||
readinessTokens = append(readinessTokens, token)
|
||||
readinessMu.Unlock()
|
||||
if token != "v3-token" {
|
||||
http.Error(w, "missing token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
writeNacosResult(w, nacosAPIV3, []any{})
|
||||
writeNacosResult(w, nacosAPIV3, "ok")
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
@@ -204,6 +416,18 @@ func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) {
|
||||
if got := recorder.countPath("/v1/auth/users/login"); got != 0 {
|
||||
t.Fatalf("legacy login count = %d, want 0", got)
|
||||
}
|
||||
wantReadinessTokens := []string{"", "v3-token", "", "v3-token"}
|
||||
readinessMu.Lock()
|
||||
gotReadinessTokens := append([]string(nil), readinessTokens...)
|
||||
readinessMu.Unlock()
|
||||
if len(gotReadinessTokens) != len(wantReadinessTokens) {
|
||||
t.Fatalf("readiness tokens = %#v, want %#v", gotReadinessTokens, wantReadinessTokens)
|
||||
}
|
||||
for index := range wantReadinessTokens {
|
||||
if gotReadinessTokens[index] != wantReadinessTokens[index] {
|
||||
t.Fatalf("readiness tokens = %#v, want %#v", gotReadinessTokens, wantReadinessTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthDoesNotFallbackAfterV3LoginForbidden(t *testing.T) {
|
||||
@@ -242,7 +466,7 @@ func TestNacosV2ListServicesWithoutGroupUsesV1Catalog(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
case nacosV3ReadinessPath:
|
||||
http.NotFound(w, request)
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
writeNacosResult(w, nacosAPIV2, []any{})
|
||||
@@ -292,8 +516,8 @@ func TestNacosV3ListServicesFiltersExactGroupAcrossPages(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
writeNacosResult(w, nacosAPIV3, []any{})
|
||||
case nacosV3ReadinessPath:
|
||||
writeNacosResult(w, nacosAPIV3, "ok")
|
||||
case routesForNacosAPI(nacosAPIV3).serviceList:
|
||||
pageNumber, _ := strconv.Atoi(request.Form.Get("pageNo"))
|
||||
pageItems := []any{
|
||||
@@ -355,12 +579,118 @@ func TestNacosV3ListServicesFiltersExactGroupAcrossPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosV3ListServicesEscapesExactGroupPattern(t *testing.T) {
|
||||
const targetGroup = "PAY[1]"
|
||||
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case nacosV3ReadinessPath:
|
||||
writeNacosResult(w, nacosAPIV3, "ok")
|
||||
case routesForNacosAPI(nacosAPIV3).serviceList:
|
||||
groupPattern, err := regexp.Compile(request.Form.Get("groupNameParam"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidates := []nacosServiceItem{
|
||||
{Name: "literal", GroupName: targetGroup},
|
||||
{Name: "regex-lookalike", GroupName: "PAY1"},
|
||||
{Name: "prefix", GroupName: targetGroup + "-ARCHIVE"},
|
||||
}
|
||||
pageItems := make([]nacosServiceItem, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if groupPattern.MatchString(candidate.GroupName) {
|
||||
pageItems = append(pageItems, candidate)
|
||||
}
|
||||
}
|
||||
writeNacosResult(w, nacosAPIV3, map[string]any{
|
||||
"totalCount": len(pageItems),
|
||||
"pageNumber": 1,
|
||||
"pagesAvailable": 1,
|
||||
"pageItems": pageItems,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := connectAPIVersionTestClient(t, server)
|
||||
defer client.Close()
|
||||
page, err := client.ListServices(context.Background(), ServiceQuery{
|
||||
NamespaceID: "dev-id",
|
||||
GroupName: targetGroup,
|
||||
PageNo: 1,
|
||||
PageSize: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListServices: %v", err)
|
||||
}
|
||||
if page.Count != 1 || len(page.ServiceNames) != 1 || page.ServiceNames[0] != targetGroup+"@@literal" {
|
||||
t.Fatalf("exact group page = %#v", page)
|
||||
}
|
||||
|
||||
request := mustLastNacosAPIRequest(t, recorder, http.MethodGet, routesForNacosAPI(nacosAPIV3).serviceList)
|
||||
if got, want := request.values.Get("groupNameParam"), regexp.QuoteMeta(targetGroup); got != want {
|
||||
t.Fatalf("groupNameParam = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEphemeralServiceAPIVersionBoundary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
family nacosAPIFamily
|
||||
}{
|
||||
{name: "v1 rejects before request", family: nacosAPIV1},
|
||||
{name: "v2 forwards ephemeral", family: nacosAPIV2},
|
||||
{name: "v3 forwards ephemeral", family: nacosAPIV3},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(nacosAPIMatrixHandler(test.family, recorder))
|
||||
defer server.Close()
|
||||
|
||||
client := connectAPIVersionTestClient(t, server)
|
||||
defer client.Close()
|
||||
ephemeral := true
|
||||
err := client.CreateService(context.Background(), CreateServiceRequest{
|
||||
NamespaceID: "dev-id",
|
||||
ServiceName: "orders",
|
||||
GroupName: "MKEFU",
|
||||
Ephemeral: &ephemeral,
|
||||
})
|
||||
routes := routesForNacosAPI(test.family)
|
||||
if test.family == nacosAPIV1 {
|
||||
if err == nil {
|
||||
t.Fatal("expected v1 ephemeral service creation to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Nacos v1") {
|
||||
t.Fatalf("CreateService error = %q, want explicit Nacos v1 boundary", err)
|
||||
}
|
||||
if _, ok := recorder.last(http.MethodPost, routes.service); ok {
|
||||
t.Fatal("v1 ephemeral service creation sent an HTTP request")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("CreateService: %v", err)
|
||||
}
|
||||
request := mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.service)
|
||||
assertNacosAPIValues(t, request.values, map[string]string{"ephemeral": "true"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosV2GetConfigPreservesJSONContent(t *testing.T) {
|
||||
recorder := &nacosAPIRequestRecorder{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
recorder.record(request)
|
||||
switch request.URL.Path {
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
case nacosV3ReadinessPath:
|
||||
http.NotFound(w, request)
|
||||
case routesForNacosAPI(nacosAPIV2).namespaceList:
|
||||
writeNacosResult(w, nacosAPIV2, []any{})
|
||||
@@ -391,18 +721,19 @@ func assertProbeSequenceAndCache(
|
||||
family nacosAPIFamily,
|
||||
) {
|
||||
t.Helper()
|
||||
readinessPath := nacosV3ReadinessPath
|
||||
v3Path := routesForNacosAPI(nacosAPIV3).namespaceList
|
||||
v2Path := routesForNacosAPI(nacosAPIV2).namespaceList
|
||||
v1Path := routesForNacosAPI(nacosAPIV1).namespaceList
|
||||
|
||||
wantBefore := map[nacosAPIFamily]map[string]int{
|
||||
nacosAPIV3: {v3Path: 2, v2Path: 0, v1Path: 0},
|
||||
nacosAPIV2: {v3Path: 1, v2Path: 2, v1Path: 0},
|
||||
nacosAPIV1: {v3Path: 1, v2Path: 1, v1Path: 2},
|
||||
nacosAPIV3: {readinessPath: 2, v3Path: 0, v2Path: 0, v1Path: 0},
|
||||
nacosAPIV2: {readinessPath: 1, v3Path: 0, v2Path: 2, v1Path: 0},
|
||||
nacosAPIV1: {readinessPath: 1, v3Path: 0, v2Path: 1, v1Path: 2},
|
||||
}[family]
|
||||
for path, want := range wantBefore {
|
||||
if got := recorder.countPath(path); got != want {
|
||||
t.Fatalf("namespace request count for %s = %d, want %d", path, got, want)
|
||||
t.Fatalf("probe request count for %s = %d, want %d", path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,19 +992,28 @@ func exerciseNacosAPIFamily(
|
||||
if err != nil {
|
||||
t.Fatalf("GetService: %v", err)
|
||||
}
|
||||
if service.Name != "orders" || service.GroupName != "MKEFU" || service.NamespaceID != "dev-id" {
|
||||
if service.Name != "orders" || service.GroupName != "MKEFU" || service.NamespaceID != "dev-id" || !service.Ephemeral {
|
||||
t.Fatalf("service detail = %#v", service)
|
||||
}
|
||||
request = mustLastNacosAPIRequest(t, recorder, http.MethodGet, routes.service)
|
||||
assertNacosNamingIdentity(t, request.values, test.qualifiedNaming)
|
||||
|
||||
serviceEphemeral := false
|
||||
if err := client.CreateService(ctx, CreateServiceRequest{
|
||||
NamespaceID: "dev-id", ServiceName: "orders", GroupName: "MKEFU", ProtectThreshold: 0.5,
|
||||
Ephemeral: &serviceEphemeral,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateService: %v", err)
|
||||
}
|
||||
request = mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.service)
|
||||
assertNacosNamingIdentity(t, request.values, test.qualifiedNaming)
|
||||
if test.family == nacosAPIV1 {
|
||||
if _, ok := request.values["ephemeral"]; ok {
|
||||
t.Fatalf("v1 CreateService unexpectedly sent ephemeral=%q", request.values.Get("ephemeral"))
|
||||
}
|
||||
} else {
|
||||
assertNacosAPIValues(t, request.values, map[string]string{"ephemeral": "false"})
|
||||
}
|
||||
if err := client.UpdateService(ctx, UpdateServiceRequest{
|
||||
NamespaceID: "dev-id", ServiceName: "orders", GroupName: "MKEFU", ProtectThreshold: 0.25,
|
||||
}); err != nil {
|
||||
@@ -703,6 +1043,7 @@ func exerciseNacosAPIFamily(
|
||||
request = mustLastNacosAPIRequest(t, recorder, http.MethodGet, routes.instanceList)
|
||||
assertNacosNamingIdentity(t, request.values, test.qualifiedNaming)
|
||||
|
||||
zeroWeight := 0.0
|
||||
instanceRequest := InstanceRequest{
|
||||
NamespaceID: "dev-id",
|
||||
ServiceName: "orders",
|
||||
@@ -710,6 +1051,7 @@ func exerciseNacosAPIFamily(
|
||||
IP: "10.0.0.1",
|
||||
Port: 8080,
|
||||
ClusterName: "DEFAULT",
|
||||
Weight: &zeroWeight,
|
||||
}
|
||||
instance, err := client.GetInstance(ctx, instanceRequest)
|
||||
if err != nil {
|
||||
@@ -730,11 +1072,19 @@ func exerciseNacosAPIFamily(
|
||||
}
|
||||
request = mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.instance)
|
||||
assertNacosNamingIdentity(t, request.values, test.qualifiedNaming)
|
||||
assertNacosAPIValues(t, request.values, map[string]string{
|
||||
"ephemeral": "false",
|
||||
"weight": "0",
|
||||
})
|
||||
if err := client.UpdateInstance(ctx, instanceRequest); err != nil {
|
||||
t.Fatalf("UpdateInstance: %v", err)
|
||||
}
|
||||
request = mustLastNacosAPIRequest(t, recorder, http.MethodPut, routes.instance)
|
||||
assertNacosNamingIdentity(t, request.values, test.qualifiedNaming)
|
||||
assertNacosAPIValues(t, request.values, map[string]string{
|
||||
"ephemeral": "false",
|
||||
"weight": "0",
|
||||
})
|
||||
if err := client.DeregisterInstance(ctx, instanceRequest); err != nil {
|
||||
t.Fatalf("DeregisterInstance: %v", err)
|
||||
}
|
||||
@@ -757,6 +1107,12 @@ func nacosAPIMatrixHandler(family nacosAPIFamily, recorder *nacosAPIRequestRecor
|
||||
recorder.record(request)
|
||||
values := request.Form
|
||||
switch {
|
||||
case request.Method == http.MethodGet && request.URL.Path == nacosV3ReadinessPath:
|
||||
if family == nacosAPIV3 {
|
||||
writeNacosResult(w, nacosAPIV3, "ok")
|
||||
} else {
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
case request.Method == http.MethodGet && request.URL.Path == routes.namespaceList:
|
||||
writeNacosResult(w, family, []map[string]any{{
|
||||
"namespace": "dev-id",
|
||||
@@ -791,6 +1147,7 @@ func nacosAPIMatrixHandler(family nacosAPIFamily, recorder *nacosAPIRequestRecor
|
||||
"name": "orders",
|
||||
"groupName": "MKEFU",
|
||||
"namespaceId": "dev-id",
|
||||
"ephemeral": true,
|
||||
"protectThreshold": 0.5,
|
||||
"metadata": map[string]string{"owner": "team-a"},
|
||||
"clusters": []any{},
|
||||
|
||||
801
internal/nacos/auth_lifecycle_test.go
Normal file
801
internal/nacos/auth_lifecycle_test.go
Normal file
@@ -0,0 +1,801 @@
|
||||
package nacos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestScopedNacosV3ConnectsViaReadinessWithoutNamespaceAdmin(t *testing.T) {
|
||||
var (
|
||||
loginRequests atomic.Int32
|
||||
readinessRequests atomic.Int32
|
||||
namespaceRequests atomic.Int32
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
loginRequests.Add(1)
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": "scoped-token",
|
||||
"tokenTtl": 3600,
|
||||
})
|
||||
case nacosV3ReadinessPath:
|
||||
readinessRequests.Add(1)
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": "ok",
|
||||
})
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
namespaceRequests.Add(1)
|
||||
http.Error(w, "namespace administrator permission required", http.StatusForbidden)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := authLifecycleServerConfig(t, server)
|
||||
config.User = "scoped-user"
|
||||
config.Password = "scoped-password"
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if client.currentAPIFamily() != nacosAPIV3 {
|
||||
t.Fatalf("API family = %d, want v3", client.currentAPIFamily())
|
||||
}
|
||||
if got := namespaceRequests.Load(); got != 0 {
|
||||
t.Fatalf("namespace requests during Connect = %d, want 0", got)
|
||||
}
|
||||
if err := client.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("Ping: %v", err)
|
||||
}
|
||||
if got := namespaceRequests.Load(); got != 0 {
|
||||
t.Fatalf("namespace requests after Ping = %d, want 0", got)
|
||||
}
|
||||
if got := readinessRequests.Load(); got != 3 {
|
||||
t.Fatalf("readiness requests = %d, want 3 (detect, Connect Ping, explicit Ping)", got)
|
||||
}
|
||||
|
||||
_, err := client.ListNamespaces(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ListNamespaces unexpectedly succeeded for scoped account")
|
||||
}
|
||||
status, ok := HTTPStatusCode(fmt.Errorf("wrapped namespace error: %w", err))
|
||||
if !ok || status != http.StatusForbidden {
|
||||
t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 403, true", err, status, ok)
|
||||
}
|
||||
if got := namespaceRequests.Load(); got != 2 {
|
||||
t.Fatalf("namespace requests = %d, want one request plus one auth retry", got)
|
||||
}
|
||||
if got := loginRequests.Load(); got != 2 {
|
||||
t.Fatalf("login requests = %d, want initial login plus one 403 retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAuthDoesNotReuseCachedConnectionOperationTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/v3/auth/user/login" {
|
||||
http.NotFound(w, request)
|
||||
return
|
||||
}
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": "refreshed-token",
|
||||
"tokenTtl": 3600,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
baseURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
client := &ClientImpl{
|
||||
config: connection.ConnectionConfig{
|
||||
User: "scoped-user",
|
||||
Password: "scoped-password",
|
||||
// Simulate a cached client first opened by a short operation.
|
||||
Timeout: 1,
|
||||
},
|
||||
httpClient: server.Client(),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := client.ensureAuth(ctx); err != nil {
|
||||
t.Fatalf("ensureAuth inherited the cached connection timeout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosHTTPErrorRedactsStructuredSecretsWithoutDroppingDiagnostics(t *testing.T) {
|
||||
const (
|
||||
accessToken = "access-token-secret"
|
||||
jsonSecret = "json-token-secret"
|
||||
formSecret = "form-password-secret"
|
||||
bearerSecret = "bearer-token-secret"
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": accessToken,
|
||||
"tokenTtl": 3600,
|
||||
})
|
||||
case nacosV3ReadinessPath:
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": "ok",
|
||||
})
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = fmt.Fprintf(
|
||||
w,
|
||||
`request failed: url=/namespace?accessToken=%s&group=DEFAULT_GROUP `+
|
||||
`json={"refreshToken":"%s","message":"keep-json"} `+
|
||||
`form=password=%s&reason=denied `+
|
||||
`Authorization: Bearer %s; ordinary token and password words remain`,
|
||||
url.QueryEscape(accessToken),
|
||||
jsonSecret,
|
||||
url.QueryEscape(formSecret),
|
||||
bearerSecret,
|
||||
)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := authLifecycleServerConfig(t, server)
|
||||
config.User = "nacos"
|
||||
config.Password = formSecret
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.ListNamespaces(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ListNamespaces unexpectedly succeeded")
|
||||
}
|
||||
if status, ok := HTTPStatusCode(err); !ok || status != http.StatusBadGateway {
|
||||
t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 502, true", err, status, ok)
|
||||
}
|
||||
message := err.Error()
|
||||
for _, secret := range []string{accessToken, jsonSecret, formSecret, bearerSecret} {
|
||||
if strings.Contains(message, secret) || strings.Contains(message, url.QueryEscape(secret)) {
|
||||
t.Fatalf("HTTP error leaked %q: %s", secret, message)
|
||||
}
|
||||
}
|
||||
for _, diagnostic := range []string{
|
||||
"group=DEFAULT_GROUP",
|
||||
`"message":"keep-json"`,
|
||||
"reason=denied",
|
||||
"ordinary token and password words remain",
|
||||
} {
|
||||
if !strings.Contains(message, diagnostic) {
|
||||
t.Fatalf("HTTP error dropped non-sensitive diagnostic %q: %s", diagnostic, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosAPIErrorRedactsStructuredSecrets(t *testing.T) {
|
||||
const (
|
||||
accessToken = "api-error-access-token"
|
||||
apiSecret = "api-error-json-secret"
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": accessToken,
|
||||
"tokenTtl": 3600,
|
||||
})
|
||||
case nacosV3ReadinessPath:
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": "ok",
|
||||
})
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 500,
|
||||
"message": fmt.Sprintf(
|
||||
`upstream rejected /namespace?accessToken=%s payload={"secret":"%s"}; keep api diagnostic %s OMITTED_TAIL`,
|
||||
url.QueryEscape(accessToken),
|
||||
apiSecret,
|
||||
strings.Repeat("x", 500),
|
||||
),
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := authLifecycleServerConfig(t, server)
|
||||
config.User = "nacos"
|
||||
config.Password = "nacos-password"
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
_, err := client.ListNamespaces(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ListNamespaces unexpectedly succeeded")
|
||||
}
|
||||
message := err.Error()
|
||||
for _, secret := range []string{accessToken, apiSecret} {
|
||||
if strings.Contains(message, secret) || strings.Contains(message, url.QueryEscape(secret)) {
|
||||
t.Fatalf("API error leaked %q: %s", secret, message)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(message, "keep api diagnostic") {
|
||||
t.Fatalf("API error dropped non-sensitive diagnostic: %s", message)
|
||||
}
|
||||
if strings.Contains(message, "OMITTED_TAIL") {
|
||||
t.Fatalf("API error message was not bounded: %s", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortLivedAuthTokenIsReusedUntilDynamicRefreshWindow(t *testing.T) {
|
||||
const accessToken = "short-lived-token"
|
||||
var loginRequests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
loginRequests.Add(1)
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": accessToken,
|
||||
"tokenTtl": 30,
|
||||
})
|
||||
case nacosV3ReadinessPath:
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": "ok",
|
||||
})
|
||||
case routesForNacosAPI(nacosAPIV3).namespaceList:
|
||||
if got := request.URL.Query().Get("accessToken"); got != accessToken {
|
||||
t.Errorf("namespace accessToken = %q, want %q", got, accessToken)
|
||||
}
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": []any{},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := authLifecycleServerConfig(t, server)
|
||||
config.User = "nacos"
|
||||
config.Password = "nacos-password"
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if _, err := client.ListNamespaces(context.Background()); err != nil {
|
||||
t.Fatalf("ListNamespaces: %v", err)
|
||||
}
|
||||
if got := loginRequests.Load(); got != 1 {
|
||||
t.Fatalf("login requests = %d, want 1 before the dynamic refresh window", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateForErrorPreservesUTF8(t *testing.T) {
|
||||
result := truncateForError(strings.Repeat("a", 399) + "界")
|
||||
if !utf8.ValidString(result) {
|
||||
t.Fatalf("truncateForError returned invalid UTF-8: %q", result)
|
||||
}
|
||||
if !strings.HasSuffix(result, "...") {
|
||||
t.Fatalf("truncateForError result = %q, want truncation suffix", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedNacosBackendDiagnosticsAreSanitized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
params map[string]any
|
||||
secret string
|
||||
diagnostic string
|
||||
}{
|
||||
{
|
||||
name: "detail",
|
||||
key: "nacos.backend.error.request_failed",
|
||||
params: map[string]any{
|
||||
"detail": "proxy failed Authorization: Basic detail-secret; keep-detail",
|
||||
},
|
||||
secret: "detail-secret",
|
||||
diagnostic: "keep-detail",
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
key: "nacos.backend.error.http_status",
|
||||
params: map[string]any{
|
||||
"status": 502,
|
||||
"body": "upstream password=body-secret&reason=keep-body",
|
||||
},
|
||||
secret: "body-secret",
|
||||
diagnostic: "reason=keep-body",
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
key: "nacos.backend.error.api_code",
|
||||
params: map[string]any{
|
||||
"code": 500,
|
||||
"message": `upstream {"refreshToken":"message-secret"} keep-message`,
|
||||
},
|
||||
secret: "message-secret",
|
||||
diagnostic: "keep-message",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
message := localizedNacosBackendError(test.key, test.params).Error()
|
||||
if strings.Contains(message, test.secret) {
|
||||
t.Fatalf("localized diagnostic leaked %q: %s", test.secret, message)
|
||||
}
|
||||
if !strings.Contains(message, test.diagnostic) {
|
||||
t.Fatalf("localized diagnostic dropped %q: %s", test.diagnostic, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedNacosBackendErrorPreservesStructuredClassification(t *testing.T) {
|
||||
httpErr := localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
|
||||
"status": http.StatusForbidden,
|
||||
"body": "permission denied",
|
||||
})
|
||||
if status, ok := HTTPStatusCode(fmt.Errorf("wrapped: %w", httpErr)); !ok || status != http.StatusForbidden {
|
||||
t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 403, true", httpErr, status, ok)
|
||||
}
|
||||
|
||||
notFoundErr := localizedNacosBackendError("nacos.backend.error.config_not_found", map[string]any{
|
||||
"group": "DEFAULT_GROUP",
|
||||
"dataId": "application.yaml",
|
||||
})
|
||||
if !IsConfigNotFound(fmt.Errorf("wrapped: %w", notFoundErr)) {
|
||||
t.Fatalf("IsConfigNotFound(%v) = false, want true", notFoundErr)
|
||||
}
|
||||
if IsConfigNotFound(errors.New("upstream config not found in an unrelated diagnostic")) {
|
||||
t.Fatal("IsConfigNotFound classified an untyped diagnostic as missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSingleflightSharesOneLogin(t *testing.T) {
|
||||
const callers = 16
|
||||
loginStarted := make(chan struct{})
|
||||
releaseLogin := make(chan struct{})
|
||||
var (
|
||||
loginCalls atomic.Int32
|
||||
startedOnce sync.Once
|
||||
releaseOnce sync.Once
|
||||
)
|
||||
release := func() {
|
||||
releaseOnce.Do(func() { close(releaseLogin) })
|
||||
}
|
||||
defer release()
|
||||
|
||||
client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path != "/nacos/v3/auth/user/login" {
|
||||
return nil, fmt.Errorf("unexpected request: %s", request.URL.Path)
|
||||
}
|
||||
loginCalls.Add(1)
|
||||
startedOnce.Do(func() { close(loginStarted) })
|
||||
<-releaseLogin
|
||||
return authLifecycleResponse(http.StatusOK, `{"accessToken":"shared-token","tokenTtl":3600}`), nil
|
||||
}), "")
|
||||
defer client.Close()
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, callers)
|
||||
var workers sync.WaitGroup
|
||||
workers.Add(callers)
|
||||
for range callers {
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
<-start
|
||||
results <- client.ensureAuth(context.Background())
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
waitAuthLifecycleSignal(t, loginStarted, "shared login start")
|
||||
release()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
workers.Wait()
|
||||
close(done)
|
||||
}()
|
||||
waitAuthLifecycleSignal(t, done, "all auth waiters")
|
||||
close(results)
|
||||
for err := range results {
|
||||
if err != nil {
|
||||
t.Errorf("ensureAuth: %v", err)
|
||||
}
|
||||
}
|
||||
if got := loginCalls.Load(); got != 1 {
|
||||
t.Fatalf("login requests = %d, want 1", got)
|
||||
}
|
||||
client.mu.Lock()
|
||||
token := client.accessToken
|
||||
client.mu.Unlock()
|
||||
if token != "shared-token" {
|
||||
t.Fatalf("access token = %q, want shared-token", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCallerCancellationDoesNotCancelSharedLogin(t *testing.T) {
|
||||
loginStarted := make(chan struct{})
|
||||
releaseLogin := make(chan struct{})
|
||||
var (
|
||||
loginCalls atomic.Int32
|
||||
startedOnce sync.Once
|
||||
releaseOnce sync.Once
|
||||
)
|
||||
release := func() {
|
||||
releaseOnce.Do(func() { close(releaseLogin) })
|
||||
}
|
||||
defer release()
|
||||
|
||||
client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
loginCalls.Add(1)
|
||||
startedOnce.Do(func() { close(loginStarted) })
|
||||
select {
|
||||
case <-releaseLogin:
|
||||
return authLifecycleResponse(http.StatusOK, `{"accessToken":"surviving-token","tokenTtl":3600}`), nil
|
||||
case <-request.Context().Done():
|
||||
return nil, request.Context().Err()
|
||||
}
|
||||
}), "")
|
||||
defer client.Close()
|
||||
|
||||
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
||||
firstResult := make(chan error, 1)
|
||||
go func() {
|
||||
firstResult <- client.ensureAuth(firstCtx)
|
||||
}()
|
||||
waitAuthLifecycleSignal(t, loginStarted, "login start")
|
||||
|
||||
secondResult := make(chan error, 1)
|
||||
go func() {
|
||||
secondResult <- client.ensureAuth(context.Background())
|
||||
}()
|
||||
cancelFirst()
|
||||
if err := waitAuthLifecycleError(t, firstResult, "canceled auth caller"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("first ensureAuth error = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
release()
|
||||
if err := waitAuthLifecycleError(t, secondResult, "surviving auth caller"); err != nil {
|
||||
t.Fatalf("second ensureAuth: %v", err)
|
||||
}
|
||||
if got := loginCalls.Load(); got != 1 {
|
||||
t.Fatalf("login requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthClosePreventsLateLoginFromOverwritingReconnect(t *testing.T) {
|
||||
oldLoginStarted := make(chan struct{})
|
||||
releaseOldLogin := make(chan struct{})
|
||||
var (
|
||||
oldLoginCalls atomic.Int32
|
||||
startedOnce sync.Once
|
||||
releaseOnce sync.Once
|
||||
)
|
||||
release := func() {
|
||||
releaseOnce.Do(func() { close(releaseOldLogin) })
|
||||
}
|
||||
defer release()
|
||||
|
||||
client := newManualAuthLifecycleClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
oldLoginCalls.Add(1)
|
||||
startedOnce.Do(func() { close(oldLoginStarted) })
|
||||
// Intentionally ignore request cancellation to exercise the generation guard.
|
||||
<-releaseOldLogin
|
||||
return authLifecycleResponse(http.StatusOK, `{"accessToken":"late-old-token","tokenTtl":3600}`), nil
|
||||
}), "")
|
||||
|
||||
oldResult := make(chan error, 1)
|
||||
go func() {
|
||||
oldResult <- client.ensureAuth(context.Background())
|
||||
}()
|
||||
waitAuthLifecycleSignal(t, oldLoginStarted, "old login start")
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close old lifecycle: %v", err)
|
||||
}
|
||||
|
||||
var newLoginCalls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Path {
|
||||
case "/v3/auth/user/login":
|
||||
newLoginCalls.Add(1)
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"accessToken": "new-generation-token",
|
||||
"tokenTtl": 3600,
|
||||
})
|
||||
case nacosV3ReadinessPath:
|
||||
writeAuthLifecycleJSON(w, map[string]any{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": "ok",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := authLifecycleServerConfig(t, server)
|
||||
config.User = "new-user"
|
||||
config.Password = "new-password"
|
||||
if err := client.Connect(config); err != nil {
|
||||
t.Fatalf("Connect new lifecycle: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
release()
|
||||
if err := waitAuthLifecycleError(t, oldResult, "old login completion"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("old ensureAuth error = %v, want context.Canceled", err)
|
||||
}
|
||||
client.mu.Lock()
|
||||
token := client.accessToken
|
||||
client.mu.Unlock()
|
||||
if token != "new-generation-token" {
|
||||
t.Fatalf("access token after old login completed = %q, want new-generation-token", token)
|
||||
}
|
||||
if got := oldLoginCalls.Load(); got != 1 {
|
||||
t.Fatalf("old login requests = %d, want 1", got)
|
||||
}
|
||||
if got := newLoginCalls.Load(); got != 1 {
|
||||
t.Fatalf("new login requests = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateUnauthorizedDoesNotInvalidateRefreshedToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestPath string
|
||||
invoke func(*ClientImpl) error
|
||||
}{
|
||||
{
|
||||
name: "ordinary request",
|
||||
requestPath: "/nacos/test-resource",
|
||||
invoke: func(client *ClientImpl) error {
|
||||
_, status, err := client.doRequest(context.Background(), http.MethodGet, "/test-resource", nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("status = %d, want 200", status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "long listener",
|
||||
requestPath: "/nacos/v1/cs/configs/listener",
|
||||
invoke: func(client *ClientImpl) error {
|
||||
_, err := client.ListenOnce(context.Background(), []ConfigListenTarget{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
NamespaceID: "dev",
|
||||
ContentMD5: ContentMD5("old"),
|
||||
}}, minListenTimeoutMs)
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
firstOldRelease := make(chan struct{})
|
||||
secondOldRelease := make(chan struct{})
|
||||
secondOldStarted := make(chan struct{})
|
||||
firstNewTokenRequest := make(chan struct{})
|
||||
var (
|
||||
oldRequests atomic.Int32
|
||||
newTokenRequests atomic.Int32
|
||||
loginRequests atomic.Int32
|
||||
secondStartedOnce sync.Once
|
||||
newTokenSeenOnce sync.Once
|
||||
firstReleaseOnce sync.Once
|
||||
secondReleaseOnce sync.Once
|
||||
)
|
||||
releaseFirst := func() {
|
||||
firstReleaseOnce.Do(func() { close(firstOldRelease) })
|
||||
}
|
||||
releaseSecond := func() {
|
||||
secondReleaseOnce.Do(func() { close(secondOldRelease) })
|
||||
}
|
||||
defer releaseFirst()
|
||||
defer releaseSecond()
|
||||
|
||||
client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch request.URL.Path {
|
||||
case "/nacos/v3/auth/user/login":
|
||||
loginRequests.Add(1)
|
||||
return authLifecycleResponse(
|
||||
http.StatusOK,
|
||||
`{"accessToken":"token-2","tokenTtl":3600}`,
|
||||
), nil
|
||||
case test.requestPath:
|
||||
switch request.URL.Query().Get("accessToken") {
|
||||
case "token-1":
|
||||
sequence := oldRequests.Add(1)
|
||||
switch sequence {
|
||||
case 1:
|
||||
<-firstOldRelease
|
||||
case 2:
|
||||
secondStartedOnce.Do(func() { close(secondOldStarted) })
|
||||
<-secondOldRelease
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected old-token request #%d", sequence)
|
||||
}
|
||||
return authLifecycleResponse(http.StatusUnauthorized, "expired token"), nil
|
||||
case "token-2":
|
||||
newTokenRequests.Add(1)
|
||||
newTokenSeenOnce.Do(func() { close(firstNewTokenRequest) })
|
||||
return authLifecycleResponse(http.StatusOK, ""), nil
|
||||
default:
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected access token %q",
|
||||
request.URL.Query().Get("accessToken"),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request: %s", request.URL.Path)
|
||||
}
|
||||
}), "token-1")
|
||||
defer client.Close()
|
||||
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
for range 2 {
|
||||
go func() {
|
||||
<-start
|
||||
results <- test.invoke(client)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
waitAuthLifecycleSignal(t, secondOldStarted, "both old-token requests")
|
||||
releaseFirst()
|
||||
waitAuthLifecycleSignal(t, firstNewTokenRequest, "first refreshed-token retry")
|
||||
releaseSecond()
|
||||
|
||||
for range 2 {
|
||||
if err := waitAuthLifecycleError(t, results, "request result"); err != nil {
|
||||
t.Errorf("request failed: %v", err)
|
||||
}
|
||||
}
|
||||
if got := oldRequests.Load(); got != 2 {
|
||||
t.Fatalf("old-token requests = %d, want 2", got)
|
||||
}
|
||||
if got := newTokenRequests.Load(); got != 2 {
|
||||
t.Fatalf("new-token requests = %d, want 2", got)
|
||||
}
|
||||
if got := loginRequests.Load(); got != 1 {
|
||||
t.Fatalf("login requests = %d, want 1", got)
|
||||
}
|
||||
client.mu.Lock()
|
||||
token := client.accessToken
|
||||
client.mu.Unlock()
|
||||
if token != "token-2" {
|
||||
t.Fatalf("access token = %q, want token-2", token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newManualAuthLifecycleClient(
|
||||
t *testing.T,
|
||||
transport http.RoundTripper,
|
||||
accessToken string,
|
||||
) *ClientImpl {
|
||||
t.Helper()
|
||||
baseURL, err := url.Parse("http://nacos.example.test/nacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiry := time.Time{}
|
||||
if accessToken != "" {
|
||||
expiry = time.Now().Add(time.Hour)
|
||||
}
|
||||
return &ClientImpl{
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
User: "nacos",
|
||||
Password: "nacos-password",
|
||||
Timeout: 2,
|
||||
},
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
baseURL: baseURL,
|
||||
apiFamily: nacosAPIV1,
|
||||
accessToken: accessToken,
|
||||
tokenExpiry: expiry,
|
||||
}
|
||||
}
|
||||
|
||||
func authLifecycleServerConfig(t *testing.T, server *httptest.Server) connection.ConnectionConfig {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: parsed.Hostname(),
|
||||
Port: port,
|
||||
Timeout: 2,
|
||||
ConnectionParams: "contextPath=/",
|
||||
}
|
||||
}
|
||||
|
||||
func authLifecycleResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthLifecycleJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func waitAuthLifecycleSignal(t *testing.T, signal <-chan struct{}, label string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", label)
|
||||
}
|
||||
}
|
||||
|
||||
func waitAuthLifecycleError(t *testing.T, result <-chan error, label string) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", label)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package nacos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"GoNavi-Wails/shared/i18n"
|
||||
@@ -36,6 +38,8 @@ func SetBackendLanguage(language i18n.Language) {
|
||||
}
|
||||
|
||||
func localizedNacosBackendText(key string, params map[string]any) string {
|
||||
params = sanitizeNacosBackendDiagnosticParams(params)
|
||||
|
||||
nacosBackendTextMu.RLock()
|
||||
if nacosBackendTextLocalizer != nil {
|
||||
text := nacosBackendTextLocalizer.T(key, params)
|
||||
@@ -57,6 +61,37 @@ func localizedNacosBackendText(key string, params map[string]any) string {
|
||||
return nacosBackendTextLocalizer.T(key, params)
|
||||
}
|
||||
|
||||
func sanitizeNacosBackendDiagnosticParams(params map[string]any) map[string]any {
|
||||
if len(params) == 0 {
|
||||
return params
|
||||
}
|
||||
sanitized := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
sanitized[key] = value
|
||||
}
|
||||
for _, key := range []string{"body", "detail", "message"} {
|
||||
if text, ok := sanitized[key].(string); ok {
|
||||
sanitized[key] = truncateForError(text)
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func localizedNacosBackendError(key string, params map[string]any) error {
|
||||
switch key {
|
||||
case "nacos.backend.error.http_status":
|
||||
status, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(params["status"])))
|
||||
if err == nil && status >= 100 && status <= 599 {
|
||||
return &nacosHTTPError{
|
||||
status: status,
|
||||
body: truncateForError(fmt.Sprint(params["body"])),
|
||||
}
|
||||
}
|
||||
case "nacos.backend.error.config_not_found":
|
||||
return &nacosConfigNotFoundError{
|
||||
group: strings.TrimSpace(fmt.Sprint(params["group"])),
|
||||
dataID: strings.TrimSpace(fmt.Sprint(params["dataId"])),
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", localizedNacosBackendText(key, params))
|
||||
}
|
||||
|
||||
@@ -9,14 +9,19 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
"GoNavi-Wails/internal/tlsconfig"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,23 +30,100 @@ const (
|
||||
defaultNacosTimeout = 30 * time.Second
|
||||
defaultConfigPageSize = 20
|
||||
maxConfigPageSize = 200
|
||||
tokenRefreshSkew = 60 * time.Second
|
||||
maxTokenRefreshSkew = 60 * time.Second
|
||||
)
|
||||
|
||||
var dialNacosProxyContext = proxytunnel.DialContext
|
||||
|
||||
var (
|
||||
nacosJSONSecretPattern = regexp.MustCompile(
|
||||
`(?i)("(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|secret|client[_-]?secret|secret[_-]?key|api[_-]?key|authorization)"\s*:\s*")((?:\\.|[^"\\])*)(")`,
|
||||
)
|
||||
nacosAuthorizationPattern = regexp.MustCompile(
|
||||
`(?i)(\bauthorization\s*[:=]\s*)(?:bearer|basic)\s+[a-z0-9._~+/%=-]+`,
|
||||
)
|
||||
nacosSecretAssignmentPattern = regexp.MustCompile(
|
||||
`(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|secret|client[_-]?secret|secret[_-]?key|api[_-]?key|authorization)\s*=\s*)([^&\s"'<>;,]+)`,
|
||||
)
|
||||
nacosBearerPattern = regexp.MustCompile(`(?i)(\bbearer\s+)[a-z0-9._~+/%=-]+`)
|
||||
)
|
||||
|
||||
type nacosForwarderLease interface {
|
||||
LocalAddress() string
|
||||
Release() error
|
||||
}
|
||||
|
||||
type nacosForwarderAcquirer func(connection.SSHConfig, string, int) (nacosForwarderLease, error)
|
||||
|
||||
type nacosAuthResult struct {
|
||||
token string
|
||||
expiry time.Time
|
||||
refreshAt time.Time
|
||||
}
|
||||
|
||||
type nacosTokenSnapshot struct {
|
||||
value string
|
||||
generation uint64
|
||||
}
|
||||
|
||||
type nacosRawResponse struct {
|
||||
body []byte
|
||||
status int
|
||||
usedToken nacosTokenSnapshot
|
||||
}
|
||||
|
||||
type localForwarderLeaseAdapter struct {
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
func (l *localForwarderLeaseAdapter) LocalAddress() string {
|
||||
if l == nil || l.forwarder == nil {
|
||||
return ""
|
||||
}
|
||||
return l.forwarder.LocalAddr
|
||||
}
|
||||
|
||||
func (l *localForwarderLeaseAdapter) Release() error {
|
||||
if l == nil || l.forwarder == nil {
|
||||
return nil
|
||||
}
|
||||
return l.forwarder.Release()
|
||||
}
|
||||
|
||||
func acquireNacosForwarder(
|
||||
sshConfig connection.SSHConfig,
|
||||
remoteHost string,
|
||||
remotePort int,
|
||||
) (nacosForwarderLease, error) {
|
||||
forwarder, err := ssh.AcquireLocalForwarder(sshConfig, remoteHost, remotePort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &localForwarderLeaseAdapter{forwarder: forwarder}, nil
|
||||
}
|
||||
|
||||
// ClientImpl is an HTTP client for the supported Nacos API families.
|
||||
type ClientImpl struct {
|
||||
mu sync.Mutex
|
||||
config connection.ConnectionConfig
|
||||
httpClient *http.Client
|
||||
baseURL *url.URL
|
||||
apiFamily nacosAPIFamily
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
mu sync.Mutex
|
||||
config connection.ConnectionConfig
|
||||
httpClient *http.Client
|
||||
baseURL *url.URL
|
||||
requestHost string
|
||||
apiFamily nacosAPIFamily
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenRefreshAt time.Time
|
||||
sshForwarder nacosForwarderLease
|
||||
acquireSSHForwarder nacosForwarderAcquirer
|
||||
authGroup *singleflight.Group
|
||||
lifecycleCtx context.Context
|
||||
lifecycleCancel context.CancelFunc
|
||||
lifecycleGeneration uint64
|
||||
}
|
||||
|
||||
// NewClient creates a new Nacos client instance.
|
||||
func NewClient() Client {
|
||||
return &ClientImpl{}
|
||||
return &ClientImpl{acquireSSHForwarder: acquireNacosForwarder}
|
||||
}
|
||||
|
||||
// Connect prepares the HTTP client and validates reachability.
|
||||
@@ -51,18 +133,60 @@ func (c *ClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
httpClient, baseURL, err := buildNacosHTTPClient(normalized)
|
||||
if err != nil {
|
||||
if err := c.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var forwarder nacosForwarderLease
|
||||
dialAddress := ""
|
||||
if normalized.UseSSH {
|
||||
acquire := c.acquireSSHForwarder
|
||||
if acquire == nil {
|
||||
acquire = acquireNacosForwarder
|
||||
}
|
||||
forwarder, err = acquire(normalized.SSH, normalized.Host, normalized.Port)
|
||||
if err != nil {
|
||||
return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
if forwarder == nil {
|
||||
return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{
|
||||
"detail": "forwarder acquisition returned no lease",
|
||||
})
|
||||
}
|
||||
dialAddress = strings.TrimSpace(forwarder.LocalAddress())
|
||||
if dialAddress == "" {
|
||||
_ = forwarder.Release()
|
||||
return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{
|
||||
"detail": "local forward address is empty",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
httpClient, baseURL, err := buildNacosHTTPClientWithDialAddress(normalized, dialAddress)
|
||||
if err != nil {
|
||||
if forwarder != nil {
|
||||
_ = forwarder.Release()
|
||||
}
|
||||
return err
|
||||
}
|
||||
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
|
||||
|
||||
c.mu.Lock()
|
||||
c.lifecycleGeneration++
|
||||
c.config = normalized
|
||||
c.httpClient = httpClient
|
||||
c.baseURL = baseURL
|
||||
c.requestHost = net.JoinHostPort(normalized.Host, strconv.Itoa(normalized.Port))
|
||||
c.apiFamily = nacosAPIUnknown
|
||||
c.accessToken = ""
|
||||
c.tokenExpiry = time.Time{}
|
||||
c.tokenRefreshAt = time.Time{}
|
||||
c.sshForwarder = forwarder
|
||||
c.authGroup = &singleflight.Group{}
|
||||
c.lifecycleCtx = lifecycleCtx
|
||||
c.lifecycleCancel = lifecycleCancel
|
||||
c.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), normalizeNacosTimeout(normalized.Timeout))
|
||||
@@ -85,21 +209,38 @@ func (c *ClientImpl) Connect(config connection.ConnectionConfig) error {
|
||||
// Close releases client resources.
|
||||
func (c *ClientImpl) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
httpClient := c.httpClient
|
||||
forwarder := c.sshForwarder
|
||||
lifecycleCancel := c.lifecycleCancel
|
||||
c.lifecycleGeneration++
|
||||
c.config = connection.ConnectionConfig{}
|
||||
c.httpClient = nil
|
||||
c.baseURL = nil
|
||||
c.requestHost = ""
|
||||
c.apiFamily = nacosAPIUnknown
|
||||
c.accessToken = ""
|
||||
c.tokenExpiry = time.Time{}
|
||||
c.tokenRefreshAt = time.Time{}
|
||||
c.sshForwarder = nil
|
||||
c.authGroup = nil
|
||||
c.lifecycleCtx = nil
|
||||
c.lifecycleCancel = nil
|
||||
c.mu.Unlock()
|
||||
if lifecycleCancel != nil {
|
||||
lifecycleCancel()
|
||||
}
|
||||
if httpClient != nil {
|
||||
httpClient.CloseIdleConnections()
|
||||
}
|
||||
if forwarder != nil {
|
||||
return forwarder.Release()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping checks server reachability via namespace list (works with/without auth).
|
||||
// Ping checks server reachability without requiring namespace administrator access.
|
||||
func (c *ClientImpl) Ping(ctx context.Context) error {
|
||||
if _, err := c.ListNamespaces(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return c.probeReadiness(ctx, c.currentAPIFamily())
|
||||
}
|
||||
|
||||
// ListNamespaces returns all namespaces including public.
|
||||
@@ -109,10 +250,7 @@ func (c *ClientImpl) ListNamespaces(ctx context.Context) ([]Namespace, error) {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
|
||||
"status": status,
|
||||
"body": truncateForError(string(body)),
|
||||
})
|
||||
return nil, nacosHTTPStatusError(status, body)
|
||||
}
|
||||
|
||||
data, err := unwrapNacosResult(body)
|
||||
@@ -992,23 +1130,95 @@ func stringifyAnyID(value any) string {
|
||||
}
|
||||
|
||||
func (c *ClientImpl) ensureAuth(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
username := strings.TrimSpace(c.config.User)
|
||||
password := c.config.Password
|
||||
needLogin := username != ""
|
||||
tokenValid := c.accessToken != "" && time.Now().Before(c.tokenExpiry.Add(-tokenRefreshSkew))
|
||||
if username == "" {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.ensureAuthLifecycleLocked()
|
||||
if c.accessTokenValidLocked(time.Now()) {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
authGroup := c.authGroup
|
||||
lifecycleCtx := c.lifecycleCtx
|
||||
generation := c.lifecycleGeneration
|
||||
c.mu.Unlock()
|
||||
|
||||
if !needLogin {
|
||||
return nil
|
||||
resultCh := authGroup.DoChan("login", func() (any, error) {
|
||||
c.mu.Lock()
|
||||
if c.lifecycleGeneration != generation || c.lifecycleCtx == nil || c.httpClient == nil {
|
||||
c.mu.Unlock()
|
||||
return nil, context.Canceled
|
||||
}
|
||||
if c.accessTokenValidLocked(time.Now()) {
|
||||
c.mu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
loginUser := strings.TrimSpace(c.config.User)
|
||||
loginPassword := c.config.Password
|
||||
// Cached clients may be shared by operations with different deadlines.
|
||||
// The caller context still controls how long that caller waits, while
|
||||
// the shared login uses a stable lifecycle timeout so the first
|
||||
// connection's short operation timeout cannot poison later refreshes.
|
||||
loginTimeout := defaultNacosTimeout
|
||||
c.mu.Unlock()
|
||||
|
||||
loginCtx, cancel := context.WithTimeout(lifecycleCtx, loginTimeout)
|
||||
defer cancel()
|
||||
authResult, err := c.login(loginCtx, loginUser, loginPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.lifecycleGeneration != generation || c.lifecycleCtx == nil ||
|
||||
c.httpClient == nil || lifecycleCtx.Err() != nil {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
c.accessToken = authResult.token
|
||||
c.tokenExpiry = authResult.expiry
|
||||
c.tokenRefreshAt = authResult.refreshAt
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case result := <-resultCh:
|
||||
return result.Err
|
||||
}
|
||||
if tokenValid {
|
||||
return nil
|
||||
}
|
||||
return c.login(ctx, username, password)
|
||||
}
|
||||
|
||||
func (c *ClientImpl) login(ctx context.Context, username, password string) error {
|
||||
func (c *ClientImpl) ensureAuthLifecycleLocked() {
|
||||
if c.authGroup != nil && c.lifecycleCtx != nil {
|
||||
return
|
||||
}
|
||||
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
|
||||
c.lifecycleGeneration++
|
||||
c.authGroup = &singleflight.Group{}
|
||||
c.lifecycleCtx = lifecycleCtx
|
||||
c.lifecycleCancel = lifecycleCancel
|
||||
}
|
||||
|
||||
func (c *ClientImpl) accessTokenValidLocked(now time.Time) bool {
|
||||
if c.accessToken == "" {
|
||||
return false
|
||||
}
|
||||
refreshAt := c.tokenRefreshAt
|
||||
if refreshAt.IsZero() {
|
||||
refreshAt = c.tokenExpiry.Add(-maxTokenRefreshSkew)
|
||||
}
|
||||
return now.Before(refreshAt)
|
||||
}
|
||||
|
||||
func (c *ClientImpl) login(ctx context.Context, username, password string) (nacosAuthResult, error) {
|
||||
query := url.Values{}
|
||||
query.Set("username", username)
|
||||
form := url.Values{}
|
||||
@@ -1023,7 +1233,7 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error
|
||||
for index, loginPath := range loginPaths {
|
||||
body, status, err = c.doRequestRaw(ctx, http.MethodPost, loginPath, query, form, false)
|
||||
if err != nil {
|
||||
return err
|
||||
return nacosAuthResult{}, err
|
||||
}
|
||||
if status >= 200 && status < 300 {
|
||||
break
|
||||
@@ -1031,7 +1241,7 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error
|
||||
if index < len(loginPaths)-1 && isUnsupportedNacosLogin(status) {
|
||||
continue
|
||||
}
|
||||
return localizedNacosBackendError("nacos.backend.error.login_failed", map[string]any{
|
||||
return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_failed", map[string]any{
|
||||
"status": status,
|
||||
"body": truncateForError(string(body)),
|
||||
})
|
||||
@@ -1042,24 +1252,31 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error
|
||||
TokenTtl int64 `json:"tokenTtl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return localizedNacosBackendError("nacos.backend.error.login_parse", map[string]any{
|
||||
return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_parse", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
token := strings.TrimSpace(payload.AccessToken)
|
||||
if token == "" {
|
||||
return localizedNacosBackendError("nacos.backend.error.login_empty_token", nil)
|
||||
return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_empty_token", nil)
|
||||
}
|
||||
ttl := payload.TokenTtl
|
||||
if ttl <= 0 {
|
||||
ttl = 18000
|
||||
}
|
||||
issuedAt := time.Now()
|
||||
ttlDuration := time.Duration(ttl) * time.Second
|
||||
refreshSkew := ttlDuration / 10
|
||||
if refreshSkew > maxTokenRefreshSkew {
|
||||
refreshSkew = maxTokenRefreshSkew
|
||||
}
|
||||
expiry := issuedAt.Add(ttlDuration)
|
||||
|
||||
c.mu.Lock()
|
||||
c.accessToken = token
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
return nacosAuthResult{
|
||||
token: token,
|
||||
expiry: expiry,
|
||||
refreshAt: expiry.Add(-refreshSkew),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isUnsupportedNacosLogin(status int) bool {
|
||||
@@ -1082,22 +1299,49 @@ func (c *ClientImpl) doRequestWithHeaders(
|
||||
if err := c.ensureAuth(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body, status, err := c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true)
|
||||
response, err := c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, true)
|
||||
if err != nil {
|
||||
return nil, status, err
|
||||
return nil, response.status, err
|
||||
}
|
||||
// Token expired mid-flight: force re-login once.
|
||||
if status == http.StatusForbidden || status == http.StatusUnauthorized {
|
||||
c.mu.Lock()
|
||||
if response.status == http.StatusForbidden || response.status == http.StatusUnauthorized {
|
||||
retry, authErr := c.reauthenticateAfterUnauthorized(ctx, response.usedToken)
|
||||
if authErr != nil {
|
||||
return nil, response.status, authErr
|
||||
}
|
||||
if retry {
|
||||
response, err = c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, true)
|
||||
if err != nil {
|
||||
return nil, response.status, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return response.body, response.status, nil
|
||||
}
|
||||
|
||||
func (c *ClientImpl) reauthenticateAfterUnauthorized(
|
||||
ctx context.Context,
|
||||
usedToken nacosTokenSnapshot,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(usedToken.value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.lifecycleGeneration != usedToken.generation {
|
||||
c.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
if c.accessToken == usedToken.value {
|
||||
c.accessToken = ""
|
||||
c.tokenExpiry = time.Time{}
|
||||
c.mu.Unlock()
|
||||
if err := c.ensureAuth(ctx); err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
return c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true)
|
||||
c.tokenRefreshAt = time.Time{}
|
||||
}
|
||||
return body, status, nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.ensureAuth(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *ClientImpl) doRequestRaw(
|
||||
@@ -1118,14 +1362,34 @@ func (c *ClientImpl) doRequestRawWithHeaders(
|
||||
headers http.Header,
|
||||
withToken bool,
|
||||
) ([]byte, int, error) {
|
||||
response, err := c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, withToken)
|
||||
return response.body, response.status, err
|
||||
}
|
||||
|
||||
func (c *ClientImpl) doRequestRawWithHeadersResult(
|
||||
ctx context.Context,
|
||||
method, apiPath string,
|
||||
query url.Values,
|
||||
form url.Values,
|
||||
headers http.Header,
|
||||
withToken bool,
|
||||
) (nacosRawResponse, error) {
|
||||
c.mu.Lock()
|
||||
httpClient := c.httpClient
|
||||
baseURL := c.baseURL
|
||||
requestHost := c.requestHost
|
||||
token := c.accessToken
|
||||
generation := c.lifecycleGeneration
|
||||
c.mu.Unlock()
|
||||
result := nacosRawResponse{
|
||||
usedToken: nacosTokenSnapshot{
|
||||
value: token,
|
||||
generation: generation,
|
||||
},
|
||||
}
|
||||
|
||||
if httpClient == nil || baseURL == nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.not_connected", nil)
|
||||
return result, localizedNacosBackendError("nacos.backend.error.not_connected", nil)
|
||||
}
|
||||
|
||||
rel := &url.URL{Path: joinAPIPath(baseURL.Path, apiPath)}
|
||||
@@ -1147,10 +1411,13 @@ func (c *ClientImpl) doRequestRawWithHeaders(
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{
|
||||
return result, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
if requestHost != "" {
|
||||
req.Host = requestHost
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
@@ -1163,19 +1430,37 @@ func (c *ClientImpl) doRequestRawWithHeaders(
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
return result, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{
|
||||
"detail": redactNacosAccessToken(err.Error(), token),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result.status = resp.StatusCode
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{
|
||||
return result, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
result.body = body
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func redactNacosAccessToken(detail, token string) string {
|
||||
detail = redactNacosErrorText(detail)
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return detail
|
||||
}
|
||||
redacted := strings.ReplaceAll(detail, url.QueryEscape(token), "[REDACTED]")
|
||||
return strings.ReplaceAll(redacted, token, "[REDACTED]")
|
||||
}
|
||||
|
||||
func redactNacosErrorText(text string) string {
|
||||
redacted := nacosJSONSecretPattern.ReplaceAllString(text, `${1}[REDACTED]${3}`)
|
||||
redacted = nacosAuthorizationPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = nacosSecretAssignmentPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
return nacosBearerPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
}
|
||||
|
||||
func normalizeNacosConfig(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
@@ -1195,6 +1480,13 @@ func normalizeNacosConfig(config connection.ConnectionConfig) (connection.Connec
|
||||
}
|
||||
|
||||
func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *url.URL, error) {
|
||||
return buildNacosHTTPClientWithDialAddress(config, "")
|
||||
}
|
||||
|
||||
func buildNacosHTTPClientWithDialAddress(
|
||||
config connection.ConnectionConfig,
|
||||
dialAddress string,
|
||||
) (*http.Client, *url.URL, error) {
|
||||
scheme := "http"
|
||||
if config.UseSSL || strings.EqualFold(strings.TrimSpace(config.SSLMode), "required") ||
|
||||
strings.EqualFold(strings.TrimSpace(config.SSLMode), "preferred") {
|
||||
@@ -1210,39 +1502,35 @@ func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *ur
|
||||
}
|
||||
base.Path = contextPath
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
dialContext := dialer.DialContext
|
||||
if dialTarget := strings.TrimSpace(dialAddress); dialTarget != "" {
|
||||
dialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, dialTarget)
|
||||
}
|
||||
} else if config.UseProxy {
|
||||
proxyConfig := config.Proxy
|
||||
dialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return dialNacosProxyContext(ctx, proxyConfig, network, address)
|
||||
}
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: dialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 32,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
if config.UseProxy && strings.EqualFold(strings.TrimSpace(config.Proxy.Type), "http") {
|
||||
proxyHost := strings.TrimSpace(config.Proxy.Host)
|
||||
if proxyHost != "" {
|
||||
proxyPort := config.Proxy.Port
|
||||
if proxyPort <= 0 {
|
||||
proxyPort = 8080
|
||||
}
|
||||
proxyURL := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: net.JoinHostPort(proxyHost, strconv.Itoa(proxyPort)),
|
||||
}
|
||||
if user := strings.TrimSpace(config.Proxy.User); user != "" {
|
||||
if pass := config.Proxy.Password; pass != "" {
|
||||
proxyURL.User = url.UserPassword(user, pass)
|
||||
} else {
|
||||
proxyURL.User = url.User(user)
|
||||
}
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
if strings.TrimSpace(dialAddress) != "" || config.UseProxy {
|
||||
// The explicit network hop is already handled by DialContext. Applying
|
||||
// an environment/http.Transport proxy as well would double-proxy it.
|
||||
transport.Proxy = nil
|
||||
}
|
||||
|
||||
if scheme == "https" {
|
||||
@@ -1261,17 +1549,26 @@ func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *ur
|
||||
})
|
||||
}
|
||||
if tlsCfg != nil {
|
||||
if strings.TrimSpace(dialAddress) != "" {
|
||||
tlsCfg.ServerName = strings.Trim(strings.TrimSpace(config.Host), "[]")
|
||||
}
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
} else {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: insecure, //nolint:gosec
|
||||
}
|
||||
if strings.TrimSpace(dialAddress) != "" {
|
||||
transport.TLSClientConfig.ServerName = strings.Trim(strings.TrimSpace(config.Host), "[]")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: normalizeNacosTimeout(config.Timeout),
|
||||
// Request deadlines are supplied by the caller context. Keeping this
|
||||
// unset prevents a cached client from retaining the first connection's
|
||||
// timeout for later operations.
|
||||
Timeout: 0,
|
||||
Transport: transport,
|
||||
}
|
||||
return client, base, nil
|
||||
@@ -1358,11 +1655,15 @@ func parseSimpleKV(raw string) map[string]string {
|
||||
|
||||
func truncateForError(text string) string {
|
||||
const max = 400
|
||||
trimmed := strings.TrimSpace(text)
|
||||
trimmed := strings.TrimSpace(redactNacosErrorText(text))
|
||||
if len(trimmed) <= max {
|
||||
return trimmed
|
||||
}
|
||||
return trimmed[:max] + "..."
|
||||
cutoff := max
|
||||
for cutoff > 0 && !utf8.RuneStart(trimmed[cutoff]) {
|
||||
cutoff--
|
||||
}
|
||||
return trimmed[:cutoff] + "..."
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
|
||||
@@ -2,18 +2,56 @@ package nacos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
type closeIdleTrackingTransport struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
type fakeNacosForwarderLease struct {
|
||||
address string
|
||||
releases atomic.Int32
|
||||
}
|
||||
|
||||
func (f *fakeNacosForwarderLease) LocalAddress() string {
|
||||
return f.address
|
||||
}
|
||||
|
||||
func (f *fakeNacosForwarderLease) Release() error {
|
||||
f.releases.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return roundTrip(request)
|
||||
}
|
||||
|
||||
func (t *closeIdleTrackingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
panic("unexpected request")
|
||||
}
|
||||
|
||||
func (t *closeIdleTrackingTransport) CloseIdleConnections() {
|
||||
t.closed = true
|
||||
}
|
||||
|
||||
func TestNormalizeNamespaceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := normalizeNamespaceID("public"); got != "" {
|
||||
@@ -36,6 +74,455 @@ func TestResolveNacosContextPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCloseClosesIdleHTTPConnections(t *testing.T) {
|
||||
transport := &closeIdleTrackingTransport{}
|
||||
client := &ClientImpl{
|
||||
config: connection.ConnectionConfig{
|
||||
User: "nacos-user",
|
||||
Password: "nacos-password",
|
||||
SSH: connection.SSHConfig{
|
||||
User: "ssh-user",
|
||||
Password: "ssh-password",
|
||||
},
|
||||
Proxy: connection.ProxyConfig{
|
||||
User: "proxy-user",
|
||||
Password: "proxy-password",
|
||||
},
|
||||
},
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if !transport.closed {
|
||||
t.Fatal("Close did not release idle HTTP connections")
|
||||
}
|
||||
if !reflect.DeepEqual(client.config, connection.ConnectionConfig{}) {
|
||||
t.Fatalf("Close retained connection config: %#v", client.config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSSHForwarderUsesRemoteTargetAndHostForRequests(t *testing.T) {
|
||||
const (
|
||||
remoteHost = "nacos.internal.test"
|
||||
remotePort = 8848
|
||||
remoteAuthority = "nacos.internal.test:8848"
|
||||
)
|
||||
var (
|
||||
ordinaryRequests atomic.Int32
|
||||
listenRequests atomic.Int32
|
||||
)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Host != remoteAuthority {
|
||||
t.Errorf("request Host = %q, want %q", request.Host, remoteAuthority)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath),
|
||||
strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"):
|
||||
http.NotFound(w, request)
|
||||
case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"):
|
||||
ordinaryRequests.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 200,
|
||||
"data": []any{},
|
||||
})
|
||||
case strings.HasSuffix(request.URL.Path, "/v1/cs/configs/listener"):
|
||||
listenRequests.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()}
|
||||
var (
|
||||
acquiredHost string
|
||||
acquiredPort int
|
||||
acquiredSSH connection.SSHConfig
|
||||
)
|
||||
client := &ClientImpl{
|
||||
acquireSSHForwarder: func(
|
||||
sshConfig connection.SSHConfig,
|
||||
host string,
|
||||
port int,
|
||||
) (nacosForwarderLease, error) {
|
||||
acquiredSSH = sshConfig
|
||||
acquiredHost = host
|
||||
acquiredPort = port
|
||||
return lease, nil
|
||||
},
|
||||
}
|
||||
sshConfig := connection.SSHConfig{
|
||||
Host: "jump.internal.test",
|
||||
Port: 22,
|
||||
User: "nacos-user",
|
||||
Password: "secret",
|
||||
}
|
||||
if err := client.Connect(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: remoteHost,
|
||||
Port: remotePort,
|
||||
UseSSH: true,
|
||||
SSH: sshConfig,
|
||||
Timeout: 2,
|
||||
ConnectionParams: "contextPath=/",
|
||||
}); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
if acquiredHost != remoteHost || acquiredPort != remotePort {
|
||||
t.Fatalf("SSH target = %s:%d, want %s:%d", acquiredHost, acquiredPort, remoteHost, remotePort)
|
||||
}
|
||||
if acquiredSSH != sshConfig {
|
||||
t.Fatalf("SSH config = %#v, want %#v", acquiredSSH, sshConfig)
|
||||
}
|
||||
|
||||
if _, err := client.ListenOnce(context.Background(), []ConfigListenTarget{{
|
||||
DataID: "application.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
ContentMD5: ContentMD5(""),
|
||||
}}, minListenTimeoutMs); err != nil {
|
||||
t.Fatalf("ListenOnce: %v", err)
|
||||
}
|
||||
if ordinaryRequests.Load() < 2 {
|
||||
t.Fatalf("ordinary Nacos requests = %d, want at least 2", ordinaryRequests.Load())
|
||||
}
|
||||
if listenRequests.Load() != 1 {
|
||||
t.Fatalf("listener requests = %d, want 1", listenRequests.Load())
|
||||
}
|
||||
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
if got := lease.releases.Load(); got != 1 {
|
||||
t.Fatalf("forwarder Release calls = %d, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSSHForwarderPreservesRemoteTLSServerName(t *testing.T) {
|
||||
const (
|
||||
remoteHost = "secure-nacos.internal.test"
|
||||
remotePort = 8848
|
||||
remoteAuthority = "secure-nacos.internal.test:8848"
|
||||
)
|
||||
sniValues := make(chan string, 4)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Host != remoteAuthority {
|
||||
t.Errorf("request Host = %q, want %q", request.Host, remoteAuthority)
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath),
|
||||
strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"):
|
||||
http.NotFound(w, request)
|
||||
case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 200,
|
||||
"data": []any{},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
select {
|
||||
case sniValues <- hello.ServerName:
|
||||
default:
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()}
|
||||
client := &ClientImpl{
|
||||
acquireSSHForwarder: func(
|
||||
connection.SSHConfig,
|
||||
string,
|
||||
int,
|
||||
) (nacosForwarderLease, error) {
|
||||
return lease, nil
|
||||
},
|
||||
}
|
||||
if err := client.Connect(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: remoteHost,
|
||||
Port: remotePort,
|
||||
UseSSL: true,
|
||||
SSLMode: "skip-verify",
|
||||
UseSSH: true,
|
||||
Timeout: 2,
|
||||
ConnectionParams: "contextPath=/",
|
||||
}); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
select {
|
||||
case got := <-sniValues:
|
||||
if got != remoteHost {
|
||||
t.Fatalf("TLS ServerName = %q, want %q", got, remoteHost)
|
||||
}
|
||||
default:
|
||||
t.Fatal("TLS handshake did not report a ServerName")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientProxyPreservesRemoteAuthorityAndTLSServerName(t *testing.T) {
|
||||
const (
|
||||
remoteHost = "secure-nacos.internal.test"
|
||||
remotePort = 8848
|
||||
remoteAuthority = "secure-nacos.internal.test:8848"
|
||||
)
|
||||
sniValues := make(chan string, 8)
|
||||
hostValues := make(chan string, 16)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
select {
|
||||
case hostValues <- request.Host:
|
||||
default:
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath),
|
||||
strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"):
|
||||
http.NotFound(w, request)
|
||||
case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 200,
|
||||
"data": []any{},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, request)
|
||||
}
|
||||
}))
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
select {
|
||||
case sniValues <- hello.ServerName:
|
||||
default:
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
originalDialNacosProxyContext := dialNacosProxyContext
|
||||
t.Cleanup(func() {
|
||||
dialNacosProxyContext = originalDialNacosProxyContext
|
||||
})
|
||||
|
||||
for _, proxyType := range []string{"http", "socks5"} {
|
||||
t.Run(proxyType, func(t *testing.T) {
|
||||
dialTargets := make(chan string, 4)
|
||||
dialProxyTypes := make(chan string, 4)
|
||||
dialNacosProxyContext = func(
|
||||
ctx context.Context,
|
||||
proxyConfig connection.ProxyConfig,
|
||||
network string,
|
||||
address string,
|
||||
) (net.Conn, error) {
|
||||
select {
|
||||
case dialTargets <- address:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case dialProxyTypes <- proxyConfig.Type:
|
||||
default:
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, server.Listener.Addr().String())
|
||||
}
|
||||
|
||||
client := &ClientImpl{}
|
||||
if err := client.Connect(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: remoteHost,
|
||||
Port: remotePort,
|
||||
UseSSL: true,
|
||||
SSLMode: "skip-verify",
|
||||
UseProxy: true,
|
||||
Proxy: connection.ProxyConfig{Type: proxyType, Host: "proxy.invalid", Port: 1080},
|
||||
Timeout: 2,
|
||||
ConnectionParams: "contextPath=/",
|
||||
}); err != nil {
|
||||
t.Fatalf("Connect through %s proxy: %v", proxyType, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
select {
|
||||
case got := <-dialTargets:
|
||||
if got != remoteAuthority {
|
||||
t.Fatalf("proxy dial target = %q, want %q", got, remoteAuthority)
|
||||
}
|
||||
default:
|
||||
t.Fatal("proxy dial hook was not called")
|
||||
}
|
||||
select {
|
||||
case got := <-dialProxyTypes:
|
||||
if got != proxyType {
|
||||
t.Fatalf("proxy dial type = %q, want %q", got, proxyType)
|
||||
}
|
||||
default:
|
||||
t.Fatal("proxy dial hook did not receive proxy config")
|
||||
}
|
||||
select {
|
||||
case got := <-sniValues:
|
||||
if got != remoteHost {
|
||||
t.Fatalf("TLS ServerName = %q, want %q", got, remoteHost)
|
||||
}
|
||||
default:
|
||||
t.Fatal("TLS handshake did not report a ServerName")
|
||||
}
|
||||
select {
|
||||
case got := <-hostValues:
|
||||
if got != remoteAuthority {
|
||||
t.Fatalf("request Host = %q, want %q", got, remoteAuthority)
|
||||
}
|
||||
default:
|
||||
t.Fatal("server did not receive a request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSSHForwarderReleasedWhenConnectFails(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "probe failed", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()}
|
||||
client := &ClientImpl{
|
||||
acquireSSHForwarder: func(
|
||||
connection.SSHConfig,
|
||||
string,
|
||||
int,
|
||||
) (nacosForwarderLease, error) {
|
||||
return lease, nil
|
||||
},
|
||||
}
|
||||
err := client.Connect(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.internal.test",
|
||||
Port: 8848,
|
||||
UseSSH: true,
|
||||
Timeout: 2,
|
||||
ConnectionParams: "contextPath=/",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Connect to fail")
|
||||
}
|
||||
if got := lease.releases.Load(); got != 1 {
|
||||
t.Fatalf("forwarder Release calls after failed Connect = %d, want exactly 1", got)
|
||||
}
|
||||
if closeErr := client.Close(); closeErr != nil {
|
||||
t.Fatalf("Close after failed Connect: %v", closeErr)
|
||||
}
|
||||
if got := lease.releases.Load(); got != 1 {
|
||||
t.Fatalf("forwarder Release calls after failed Connect cleanup = %d, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientSSHForwarderRejectsNilLease(t *testing.T) {
|
||||
client := &ClientImpl{
|
||||
acquireSSHForwarder: func(
|
||||
connection.SSHConfig,
|
||||
string,
|
||||
int,
|
||||
) (nacosForwarderLease, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
err := client.Connect(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: "nacos.internal.test",
|
||||
Port: 8848,
|
||||
UseSSH: true,
|
||||
ConnectionParams: "contextPath=/",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil SSH forwarder lease to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosHTTPClientReliesOnRequestContextDeadline(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||
<-request.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
httpClient, baseURL, err := buildNacosHTTPClient(connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
Host: parsed.Hostname(),
|
||||
Port: port,
|
||||
Timeout: 1,
|
||||
ConnectionParams: "contextPath=/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildNacosHTTPClient: %v", err)
|
||||
}
|
||||
if httpClient.Timeout != 0 {
|
||||
t.Fatalf("http client timeout = %s, want 0", httpClient.Timeout)
|
||||
}
|
||||
|
||||
client := &ClientImpl{
|
||||
httpClient: httpClient,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, _, err = client.doRequestRaw(ctx, http.MethodGet, "/slow", nil, nil, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected context deadline error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
|
||||
t.Fatalf("request error = %q, want context deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosRequestErrorRedactsAccessToken(t *testing.T) {
|
||||
const accessToken = "request-error-secret+/= token"
|
||||
baseURL, err := url.Parse("http://nacos.example.test/nacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &ClientImpl{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
}),
|
||||
},
|
||||
baseURL: baseURL,
|
||||
accessToken: accessToken,
|
||||
}
|
||||
|
||||
_, _, err = client.doRequestRaw(context.Background(), http.MethodGet, "/v1/console/namespaces", nil, nil, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected request failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), accessToken) {
|
||||
t.Fatalf("request error exposed access token: %q", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), url.QueryEscape(accessToken)) {
|
||||
t.Fatalf("request error exposed encoded access token: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientConfigFlow(t *testing.T) {
|
||||
var (
|
||||
gotLoginUser string
|
||||
|
||||
@@ -80,7 +80,7 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge
|
||||
listenCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs+10000)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
body, status, err := c.doListenRequest(listenCtx, form, timeoutMs)
|
||||
response, err := c.doListenRequestResult(listenCtx, form, timeoutMs)
|
||||
if err != nil {
|
||||
// Context cancel/deadline is expected when stopping listeners.
|
||||
if listenCtx.Err() != nil && (ctx.Err() != nil || listenCtx.Err() == context.DeadlineExceeded) {
|
||||
@@ -92,30 +92,32 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusForbidden || status == http.StatusUnauthorized {
|
||||
c.mu.Lock()
|
||||
c.accessToken = ""
|
||||
c.tokenExpiry = time.Time{}
|
||||
c.mu.Unlock()
|
||||
if err := c.ensureAuth(ctx); err != nil {
|
||||
return nil, err
|
||||
if response.status == http.StatusForbidden || response.status == http.StatusUnauthorized {
|
||||
retry, authErr := c.reauthenticateAfterUnauthorized(ctx, response.usedToken)
|
||||
if authErr != nil {
|
||||
return nil, authErr
|
||||
}
|
||||
body, status, err = c.doListenRequest(listenCtx, form, timeoutMs)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
if retry {
|
||||
response, err = c.doListenRequestResult(listenCtx, form, timeoutMs)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if listenCtx.Err() == context.DeadlineExceeded {
|
||||
return []ConfigListenTarget{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return []ConfigListenTarget{}, nil
|
||||
}
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
|
||||
"status": status,
|
||||
"body": truncateForError(string(body)),
|
||||
"status": response.status,
|
||||
"body": truncateForError(string(response.body)),
|
||||
})
|
||||
}
|
||||
|
||||
changed := parseListenResponse(string(body))
|
||||
changed := parseListenResponse(string(response.body))
|
||||
if len(changed) == 0 {
|
||||
return []ConfigListenTarget{}, nil
|
||||
}
|
||||
@@ -123,14 +125,31 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge
|
||||
}
|
||||
|
||||
func (c *ClientImpl) doListenRequest(ctx context.Context, form url.Values, timeoutMs int) ([]byte, int, error) {
|
||||
response, err := c.doListenRequestResult(ctx, form, timeoutMs)
|
||||
return response.body, response.status, err
|
||||
}
|
||||
|
||||
func (c *ClientImpl) doListenRequestResult(
|
||||
ctx context.Context,
|
||||
form url.Values,
|
||||
timeoutMs int,
|
||||
) (nacosRawResponse, error) {
|
||||
c.mu.Lock()
|
||||
baseClient := c.httpClient
|
||||
baseURL := c.baseURL
|
||||
requestHost := c.requestHost
|
||||
token := c.accessToken
|
||||
generation := c.lifecycleGeneration
|
||||
c.mu.Unlock()
|
||||
result := nacosRawResponse{
|
||||
usedToken: nacosTokenSnapshot{
|
||||
value: token,
|
||||
generation: generation,
|
||||
},
|
||||
}
|
||||
|
||||
if baseClient == nil || baseURL == nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.not_connected", nil)
|
||||
return result, localizedNacosBackendError("nacos.backend.error.not_connected", nil)
|
||||
}
|
||||
|
||||
// Avoid inherited short Timeout from the shared client.
|
||||
@@ -150,29 +169,34 @@ func (c *ClientImpl) doListenRequest(ctx context.Context, form url.Values, timeo
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{
|
||||
return result, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
if requestHost != "" {
|
||||
req.Host = requestHost
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Long-Pulling-Timeout", strconv.Itoa(timeoutMs))
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
resp, err := listenClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{
|
||||
"detail": err.Error(),
|
||||
return result, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{
|
||||
"detail": redactNacosAccessToken(err.Error(), token),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
result.status = resp.StatusCode
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{
|
||||
return result, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
result.body = body
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseListenResponse(raw string) []ConfigListenTarget {
|
||||
|
||||
@@ -2,6 +2,7 @@ package nacos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -39,6 +40,103 @@ func TestParseListenResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNacosListenRequestErrorRedactsAccessToken(t *testing.T) {
|
||||
const accessToken = "listen-error-secret+/= token"
|
||||
baseURL, err := url.Parse("http://nacos.example.test/nacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &ClientImpl{
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
}),
|
||||
},
|
||||
baseURL: baseURL,
|
||||
accessToken: accessToken,
|
||||
}
|
||||
|
||||
_, _, err = client.doListenRequest(context.Background(), url.Values{
|
||||
"Listening-Configs": {"app.yaml"},
|
||||
}, defaultListenTimeoutMs)
|
||||
if err == nil {
|
||||
t.Fatal("expected listen request failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), accessToken) {
|
||||
t.Fatalf("listen request error exposed access token: %q", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), url.QueryEscape(accessToken)) {
|
||||
t.Fatalf("listen request error exposed encoded access token: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenOnceReturnsTransportErrorAfterReauthentication(t *testing.T) {
|
||||
const (
|
||||
oldToken = "old-token"
|
||||
newToken = "new-token"
|
||||
)
|
||||
baseURL, err := url.Parse("http://nacos.example.test/nacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listenRequests := 0
|
||||
loginRequests := 0
|
||||
client := &ClientImpl{
|
||||
config: connection.ConnectionConfig{
|
||||
Type: "nacos",
|
||||
User: "nacos",
|
||||
Password: "nacos-password",
|
||||
},
|
||||
httpClient: &http.Client{
|
||||
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(request.URL.Path, "/v1/cs/configs/listener"):
|
||||
listenRequests++
|
||||
if listenRequests == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("unauthorized")),
|
||||
}, nil
|
||||
}
|
||||
return nil, errors.New("retry transport failed")
|
||||
case strings.HasSuffix(request.URL.Path, "/v3/auth/user/login"):
|
||||
loginRequests++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"` + newToken + `","tokenTtl":3600}`,
|
||||
)),
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected request: " + request.URL.Path)
|
||||
}
|
||||
}),
|
||||
},
|
||||
baseURL: baseURL,
|
||||
apiFamily: nacosAPIV1,
|
||||
accessToken: oldToken,
|
||||
tokenExpiry: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
_, err = client.ListenOnce(context.Background(), []ConfigListenTarget{{
|
||||
DataID: "app.yaml",
|
||||
Group: "DEFAULT_GROUP",
|
||||
ContentMD5: ContentMD5("old"),
|
||||
NamespaceID: "dev",
|
||||
}}, minListenTimeoutMs)
|
||||
if err == nil || !strings.Contains(err.Error(), "retry transport failed") {
|
||||
t.Fatalf("ListenOnce error = %v, want retry transport failure", err)
|
||||
}
|
||||
if listenRequests != 2 {
|
||||
t.Fatalf("listen requests = %d, want 2", listenRequests)
|
||||
}
|
||||
if loginRequests != 1 {
|
||||
t.Fatalf("login requests = %d, want 1", loginRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenOnceChanged(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -99,8 +197,8 @@ func TestListenOnceV3PollsConfigByMD5(t *testing.T) {
|
||||
var legacyListenRequests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list"):
|
||||
_, _ = io.WriteString(w, `{"code":0,"message":"success","data":[]}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath):
|
||||
_, _ = io.WriteString(w, `{"code":0,"message":"success","data":"ok"}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/cs/config"):
|
||||
if r.URL.Query().Get("dataId") != "app.yaml" || r.URL.Query().Get("groupName") != "DEFAULT_GROUP" ||
|
||||
r.URL.Query().Get("namespaceId") != "dev" {
|
||||
@@ -153,8 +251,8 @@ func TestListenOnceV3PollsConfigByMD5(t *testing.T) {
|
||||
func TestListenOnceV3BoundsSlowPollByListenTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list"):
|
||||
_, _ = io.WriteString(w, `{"code":0,"message":"success","data":[]}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath):
|
||||
_, _ = io.WriteString(w, `{"code":0,"message":"success","data":"ok"}`)
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/cs/config"):
|
||||
<-r.Context().Done()
|
||||
default:
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -167,7 +168,7 @@ func (c *ClientImpl) listV3ServicesByExactGroup(
|
||||
params.Set("pageSize", strconv.Itoa(maxServicePageSize))
|
||||
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
|
||||
params.Set("serviceNameParam", "")
|
||||
params.Set("groupNameParam", groupName)
|
||||
params.Set("groupNameParam", regexp.QuoteMeta(groupName))
|
||||
|
||||
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().serviceListByGroup, params, nil)
|
||||
if err != nil {
|
||||
@@ -261,6 +262,7 @@ func (c *ClientImpl) GetService(ctx context.Context, namespaceID, serviceName, g
|
||||
GroupName string `json:"groupName"`
|
||||
NamespaceID string `json:"namespaceId"`
|
||||
Namespace string `json:"namespace"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
ProtectThreshold float64 `json:"protectThreshold"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Selector map[string]any `json:"selector"`
|
||||
@@ -306,6 +308,7 @@ func (c *ClientImpl) GetService(ctx context.Context, namespaceID, serviceName, g
|
||||
Name: firstNonEmpty(strings.TrimSpace(payload.Name), strings.TrimSpace(payload.ServiceName), plainServiceName),
|
||||
GroupName: firstNonEmpty(strings.TrimSpace(payload.GroupName), groupName),
|
||||
NamespaceID: normalizeNamespaceID(firstNonEmpty(payload.NamespaceID, payload.Namespace, namespaceID)),
|
||||
Ephemeral: payload.Ephemeral,
|
||||
ProtectThreshold: payload.ProtectThreshold,
|
||||
Metadata: payload.Metadata,
|
||||
Selector: payload.Selector,
|
||||
@@ -320,6 +323,9 @@ func (c *ClientImpl) CreateService(ctx context.Context, req CreateServiceRequest
|
||||
if serviceName == "" {
|
||||
return localizedNacosBackendError("nacos.backend.error.service_name_required", nil)
|
||||
}
|
||||
if family == nacosAPIV1 && req.Ephemeral != nil && *req.Ephemeral {
|
||||
return localizedNacosBackendError("nacos.backend.error.ephemeral_service_unsupported_v1", nil)
|
||||
}
|
||||
serviceName, groupName := splitServiceName(serviceName, req.GroupName)
|
||||
form := url.Values{}
|
||||
if family == nacosAPIV1 {
|
||||
@@ -329,6 +335,9 @@ func (c *ClientImpl) CreateService(ctx context.Context, req CreateServiceRequest
|
||||
}
|
||||
form.Set("groupName", groupName)
|
||||
form.Set("namespaceId", normalizeNamespaceID(req.NamespaceID))
|
||||
if family != nacosAPIV1 && req.Ephemeral != nil {
|
||||
form.Set("ephemeral", strconv.FormatBool(*req.Ephemeral))
|
||||
}
|
||||
form.Set("protectThreshold", strconv.FormatFloat(req.ProtectThreshold, 'f', -1, 64))
|
||||
if meta := encodeMetadata(req.Metadata); meta != "" {
|
||||
form.Set("metadata", meta)
|
||||
@@ -638,8 +647,8 @@ func buildInstanceParams(req InstanceRequest, includeEphemeral bool, family naco
|
||||
func buildInstanceForm(req InstanceRequest, includeAttrs bool, family nacosAPIFamily) url.Values {
|
||||
form := buildInstanceParams(req, true, family)
|
||||
if includeAttrs {
|
||||
if req.Weight > 0 {
|
||||
form.Set("weight", strconv.FormatFloat(req.Weight, 'f', -1, 64))
|
||||
if req.Weight != nil {
|
||||
form.Set("weight", strconv.FormatFloat(*req.Weight, 'f', -1, 64))
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
form.Set("enabled", strconv.FormatBool(*req.Enabled))
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) {
|
||||
"name": "orders",
|
||||
"groupName": "DEFAULT_GROUP",
|
||||
"namespaceId": "dev",
|
||||
"ephemeral": true,
|
||||
"protectThreshold": 0.5,
|
||||
"metadata": map[string]string{"owner": "team-a"},
|
||||
"clusters": []map[string]any{
|
||||
@@ -118,7 +119,7 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetService: %v", err)
|
||||
}
|
||||
if detail.Name != "orders" || detail.ProtectThreshold != 0.5 {
|
||||
if detail.Name != "orders" || !detail.Ephemeral || detail.ProtectThreshold != 0.5 {
|
||||
t.Fatalf("service detail = %#v", detail)
|
||||
}
|
||||
|
||||
@@ -148,13 +149,14 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) {
|
||||
|
||||
ephemeral := true
|
||||
enabled := true
|
||||
weight := 1.0
|
||||
if err := client.RegisterInstance(ctx, InstanceRequest{
|
||||
NamespaceID: "dev",
|
||||
ServiceName: "orders",
|
||||
GroupName: "DEFAULT_GROUP",
|
||||
IP: "10.0.0.2",
|
||||
Port: 8081,
|
||||
Weight: 1,
|
||||
Weight: &weight,
|
||||
Enabled: &enabled,
|
||||
Ephemeral: &ephemeral,
|
||||
ClusterName: "DEFAULT",
|
||||
@@ -271,11 +273,16 @@ func TestNamingOperationsQualifyNonDefaultGroup(t *testing.T) {
|
||||
protectThreshold string
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list") ||
|
||||
if r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath) ||
|
||||
strings.HasSuffix(r.URL.Path, nacosV2ReadinessPath) ||
|
||||
strings.HasSuffix(r.URL.Path, "/v2/console/namespace/list")) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV1ReadinessPath) {
|
||||
_, _ = io.WriteString(w, "OK")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v1/console/namespaces") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": []any{}})
|
||||
return
|
||||
|
||||
@@ -185,6 +185,7 @@ type ServiceDetail struct {
|
||||
Name string `json:"name"`
|
||||
GroupName string `json:"groupName,omitempty"`
|
||||
NamespaceID string `json:"namespaceId,omitempty"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
ProtectThreshold float64 `json:"protectThreshold,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Selector map[string]any `json:"selector,omitempty"`
|
||||
@@ -203,6 +204,7 @@ type CreateServiceRequest struct {
|
||||
NamespaceID string `json:"namespaceId"`
|
||||
ServiceName string `json:"serviceName"`
|
||||
GroupName string `json:"groupName,omitempty"`
|
||||
Ephemeral *bool `json:"ephemeral,omitempty"`
|
||||
ProtectThreshold float64 `json:"protectThreshold,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -256,7 +258,7 @@ type InstanceRequest struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
ClusterName string `json:"clusterName,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Healthy *bool `json:"healthy,omitempty"`
|
||||
Ephemeral *bool `json:"ephemeral,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user