mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 23:23:52 +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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user