🐛 fix(nacos): 完善多版本连接与配置服务可靠性

- 支持 Nacos v1、v2、v3 就绪探测与受限账号连接
- 强化认证、缓存、单飞连接和关闭代际的并发边界
- 修复监听重复事件、导入身份碰撞及部分失败误报成功
- 补齐只读保护、结构化错误与六语言提示
This commit is contained in:
Syngnat
2026-07-28 23:51:17 +08:00
parent 199ab4fea1
commit e5e80cbbe8
26 changed files with 5152 additions and 354 deletions

View File

@@ -29,6 +29,7 @@ var connectionReadOnlySupportedTypes = map[string]struct{}{
"mariadb": {},
"mongodb": {},
"mysql": {},
"nacos": {},
"oceanbase": {},
"opengauss": {},
"oracle": {},

View File

@@ -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")
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}

View 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)
}
}

View 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)
}
})
}
}

View File

@@ -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 {

View 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")
}
}