mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 16:21:23 +08:00
✨ feat(i18n): 推进多语言剩余切片闭环
- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试 - 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径 - 更新六语言共享词典并保留 raw 边界
This commit is contained in:
@@ -5,7 +5,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -39,6 +39,25 @@ type jvmPreviewConfirmationContext struct {
|
||||
AfterVersion string `json:"afterVersion"`
|
||||
}
|
||||
|
||||
type jvmPreviewConfirmationHashError struct {
|
||||
key string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *jvmPreviewConfirmationHashError) Error() string {
|
||||
if e == nil || e.err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *jvmPreviewConfirmationHashError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.err
|
||||
}
|
||||
|
||||
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
|
||||
probeCfg := cfg
|
||||
probeCfg.JVM.PreferredMode = mode
|
||||
@@ -72,7 +91,7 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co
|
||||
func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
|
||||
contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", a.localizeJVMPreviewConfirmationHashError(err)
|
||||
}
|
||||
|
||||
token := uuid.NewString()
|
||||
@@ -101,17 +120,17 @@ func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig
|
||||
}
|
||||
|
||||
if strings.TrimSpace(preview.ConfirmationToken) == "" {
|
||||
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
|
||||
return errors.New(a.appText("jvm.backend.error.preview_confirmation_missing", nil))
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(req.ConfirmationToken)
|
||||
if token == "" {
|
||||
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
|
||||
return errors.New(a.appText("jvm.backend.error.confirmation_token_missing", nil))
|
||||
}
|
||||
|
||||
expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
|
||||
if err != nil {
|
||||
return err
|
||||
return a.localizeJVMPreviewConfirmationHashError(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -119,21 +138,21 @@ func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig
|
||||
if a.jvmPreviewTokens == nil {
|
||||
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
|
||||
}
|
||||
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
|
||||
entry, ok := a.jvmPreviewTokens[token]
|
||||
if ok {
|
||||
delete(a.jvmPreviewTokens, token)
|
||||
}
|
||||
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
|
||||
a.jvmPreviewTokenMu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("确认令牌已失效,请重新预览并确认")
|
||||
return errors.New(a.appText("jvm.backend.error.confirmation_token_invalid", nil))
|
||||
}
|
||||
if !entry.expiresAt.After(now) {
|
||||
return fmt.Errorf("确认令牌已过期,请重新预览并确认")
|
||||
return errors.New(a.appText("jvm.backend.error.confirmation_token_expired", nil))
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 {
|
||||
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
|
||||
return errors.New(a.appText("jvm.backend.error.confirmation_token_invalid", nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,11 +168,17 @@ func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) {
|
||||
func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
|
||||
configHash, err := hashJSONValue(cfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err)
|
||||
return "", &jvmPreviewConfirmationHashError{
|
||||
key: "jvm.backend.error.preview_context_hash_failed",
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
payloadHash, err := hashJSONValue(req.Payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err)
|
||||
return "", &jvmPreviewConfirmationHashError{
|
||||
key: "jvm.backend.error.preview_payload_hash_failed",
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
input := jvmPreviewConfirmationContext{
|
||||
@@ -173,6 +198,51 @@ func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req
|
||||
return hashJSONValue(input)
|
||||
}
|
||||
|
||||
func (a *App) localizeJVMPreviewConfirmationHashError(err error) error {
|
||||
var hashErr *jvmPreviewConfirmationHashError
|
||||
if !errors.As(err, &hashErr) || hashErr == nil {
|
||||
return err
|
||||
}
|
||||
return errors.New(a.appText(hashErr.key, map[string]any{
|
||||
"detail": hashErr.Error(),
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *App) localizeJVMError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var localized *jvm.LocalizedError
|
||||
if errors.As(err, &localized) && localized != nil && strings.TrimSpace(localized.Key) != "" {
|
||||
return a.appText(localized.Key, localized.Params)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (a *App) localizeJVMBlockingReason(preview jvm.ChangePreview) string {
|
||||
reason := strings.TrimSpace(preview.BlockingReason)
|
||||
if reason == "" {
|
||||
return ""
|
||||
}
|
||||
key := strings.TrimSpace(preview.BlockingReasonLocalizationKey())
|
||||
if key != "" && reason == key {
|
||||
return a.appText(key, nil)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func (a *App) localizeJVMCapability(capability jvm.Capability) jvm.Capability {
|
||||
reason := strings.TrimSpace(capability.Reason)
|
||||
if reason == "" {
|
||||
return capability
|
||||
}
|
||||
key := strings.TrimSpace(capability.ReasonLocalizationKey())
|
||||
if key != "" && reason == key {
|
||||
capability.Reason = a.appText(key, nil)
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
func hashJSONValue(value any) (string, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
@@ -192,7 +262,7 @@ func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.Quer
|
||||
return connection.QueryResult{Success: false, Message: jvm.DescribeConnectionTestError(normalized, err)}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
|
||||
return connection.QueryResult{Success: true, Message: a.appText("jvm.backend.message.connect_success", nil)}
|
||||
}
|
||||
|
||||
func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult {
|
||||
@@ -232,12 +302,12 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
|
||||
|
||||
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
if preview.Allowed && preview.RequiresConfirmation {
|
||||
token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview)
|
||||
@@ -246,6 +316,7 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
|
||||
}
|
||||
preview.ConfirmationToken = token
|
||||
}
|
||||
preview.BlockingReason = a.localizeJVMBlockingReason(preview)
|
||||
|
||||
return connection.QueryResult{Success: true, Data: preview}
|
||||
}
|
||||
@@ -259,17 +330,17 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
|
||||
|
||||
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
if !preview.Allowed {
|
||||
message := strings.TrimSpace(preview.BlockingReason)
|
||||
message := a.localizeJVMBlockingReason(preview)
|
||||
if message == "" {
|
||||
message = "当前变更被 Guard 拦截"
|
||||
message = a.appText("jvm.backend.error.change_blocked_by_guard", nil)
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: message}
|
||||
}
|
||||
@@ -302,7 +373,7 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
|
||||
if message == "" {
|
||||
return warning
|
||||
}
|
||||
return message + ";" + warning
|
||||
return message + a.appText("jvm.backend.separator.message_warning", nil) + warning
|
||||
}
|
||||
|
||||
pendingTimestamp := time.Now().UnixMilli()
|
||||
@@ -315,13 +386,13 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
|
||||
}
|
||||
|
||||
if err := appendAudit("pending", pendingTimestamp); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.error.audit_write_blocked", map[string]any{"detail": err.Error()})}
|
||||
}
|
||||
|
||||
result, err := provider.ApplyChange(a.ctx, normalized, req)
|
||||
if err != nil {
|
||||
if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()}
|
||||
return connection.QueryResult{Success: false, Message: appendWarning(err.Error(), a.appText("jvm.backend.warning.failed_audit_write_failed", map[string]any{"detail": auditErr.Error()}))}
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
@@ -331,7 +402,7 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
|
||||
terminalResult = "applied"
|
||||
}
|
||||
if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil {
|
||||
result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error())
|
||||
result.Message = appendWarning(result.Message, a.appText("jvm.backend.warning.terminal_audit_write_failed", map[string]any{"detail": err.Error()}))
|
||||
return connection.QueryResult{Success: true, Message: result.Message, Data: result}
|
||||
}
|
||||
|
||||
@@ -369,7 +440,9 @@ func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.Q
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, caps...)
|
||||
for _, cap := range caps {
|
||||
items = append(items, a.localizeJVMCapability(cap))
|
||||
}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: items}
|
||||
|
||||
@@ -39,7 +39,9 @@ func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.
|
||||
return connection.ConnectionConfig{}, nil, err
|
||||
}
|
||||
if !diagCfg.Enabled {
|
||||
return connection.ConnectionConfig{}, nil, errors.New("当前连接未启用 JVM 诊断增强模式")
|
||||
return connection.ConnectionConfig{}, nil, &jvm.LocalizedError{
|
||||
Key: "jvm.backend.diagnostic.error.disabled",
|
||||
}
|
||||
}
|
||||
normalized.JVM.Diagnostic = diagCfg
|
||||
|
||||
@@ -53,12 +55,12 @@ func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.
|
||||
func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
|
||||
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
items, err := transport.ProbeCapabilities(a.ctx, normalized)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: items}
|
||||
}
|
||||
@@ -66,12 +68,12 @@ func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) co
|
||||
func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) connection.QueryResult {
|
||||
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
handle, err := transport.StartSession(a.ctx, normalized, req)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: handle}
|
||||
}
|
||||
@@ -79,7 +81,7 @@ func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm
|
||||
func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, req jvm.DiagnosticCommandRequest) connection.QueryResult {
|
||||
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
redactor := jvm.NewDiagnosticOutputRedactor()
|
||||
@@ -91,10 +93,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
|
||||
if req.SessionID == "" {
|
||||
return connection.QueryResult{Success: false, Message: "诊断会话 ID 不能为空,请先创建会话"}
|
||||
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.session_id_required", nil)}
|
||||
}
|
||||
if req.Command == "" {
|
||||
return connection.QueryResult{Success: false, Message: "诊断命令不能为空"}
|
||||
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.command_required", nil)}
|
||||
}
|
||||
if req.CommandID == "" {
|
||||
req.CommandID = fmt.Sprintf("diag-%d", time.Now().UnixNano())
|
||||
@@ -105,7 +107,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
|
||||
commandType, err := jvm.ValidateDiagnosticExecutionPolicy(normalized, req.Command)
|
||||
if err != nil {
|
||||
message := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
|
||||
message := redactor.RedactContent(req.SessionID, req.CommandID, a.localizeJVMError(err))
|
||||
return connection.QueryResult{Success: false, Message: message}
|
||||
}
|
||||
riskLevel := diagnosticRiskLevel(commandType)
|
||||
@@ -124,7 +126,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
RiskLevel: riskLevel,
|
||||
Status: "running",
|
||||
}); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: "诊断审计记录写入失败,已阻止命令执行: " + err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.audit_write_blocked", map[string]any{"detail": err.Error()})}
|
||||
}
|
||||
|
||||
terminalSeen := false
|
||||
@@ -145,7 +147,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
RiskLevel: riskLevel,
|
||||
Status: status,
|
||||
}); err != nil {
|
||||
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
|
||||
auditWarnings = append(auditWarnings, a.appText("jvm.backend.diagnostic.warning.audit_write_failed", map[string]any{"detail": err.Error()}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +158,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
}
|
||||
chunk.SessionID = req.SessionID
|
||||
chunk.CommandID = req.CommandID
|
||||
chunk = a.localizeDiagnosticChunkContent(chunk)
|
||||
chunk = redactor.RedactChunk(chunk)
|
||||
a.emitDiagnosticChunk(tabID, chunk)
|
||||
if isDiagnosticTerminalPhase(chunk.Phase) {
|
||||
@@ -166,10 +169,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
|
||||
if err := transport.ExecuteCommand(a.ctx, normalized, req); err != nil {
|
||||
phase := "failed"
|
||||
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
|
||||
if isDiagnosticCanceledError(err) {
|
||||
phase = "canceled"
|
||||
}
|
||||
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
|
||||
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, a.localizeJVMError(err))
|
||||
if !terminalSeen {
|
||||
chunk := jvm.DiagnosticEventChunk{
|
||||
SessionID: req.SessionID,
|
||||
@@ -182,7 +185,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
a.emitDiagnosticChunk(tabID, chunk)
|
||||
appendTerminalAudit(phase)
|
||||
}
|
||||
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(redactedError, auditWarnings)}
|
||||
return connection.QueryResult{Success: false, Message: a.joinDiagnosticMessages(redactedError, auditWarnings)}
|
||||
}
|
||||
|
||||
if !terminalSeen {
|
||||
@@ -191,7 +194,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
CommandID: req.CommandID,
|
||||
Event: "diagnostic",
|
||||
Phase: "completed",
|
||||
Content: "诊断命令执行完成",
|
||||
Content: a.appText("jvm.backend.diagnostic.message.command_completed", nil),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
a.emitDiagnosticChunk(tabID, chunk)
|
||||
@@ -200,7 +203,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: joinDiagnosticMessages("", auditWarnings),
|
||||
Message: a.joinDiagnosticMessages("", auditWarnings),
|
||||
Data: map[string]any{
|
||||
"sessionId": req.SessionID,
|
||||
"commandId": req.CommandID,
|
||||
@@ -212,17 +215,17 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, sessionID string, commandID string) connection.QueryResult {
|
||||
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if sessionID == "" || commandID == "" {
|
||||
return connection.QueryResult{Success: false, Message: "取消命令缺少 sessionId 或 commandId"}
|
||||
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.cancel_identifiers_required", nil)}
|
||||
}
|
||||
|
||||
if err := transport.CancelCommand(a.ctx, normalized, sessionID, commandID); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
a.emitDiagnosticChunk(tabID, jvm.DiagnosticEventChunk{
|
||||
@@ -230,7 +233,7 @@ func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID
|
||||
CommandID: commandID,
|
||||
Event: "diagnostic",
|
||||
Phase: "canceling",
|
||||
Content: "已发送取消请求,等待诊断桥接端结束命令",
|
||||
Content: a.appText("jvm.backend.diagnostic.message.cancel_requested", nil),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
return connection.QueryResult{
|
||||
@@ -261,6 +264,24 @@ func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) localizeDiagnosticChunkContent(chunk jvm.DiagnosticEventChunk) jvm.DiagnosticEventChunk {
|
||||
if chunk.Metadata == nil {
|
||||
return chunk
|
||||
}
|
||||
contentKey, ok := chunk.Metadata["contentKey"].(string)
|
||||
contentKey = strings.TrimSpace(contentKey)
|
||||
if !ok || contentKey == "" {
|
||||
return chunk
|
||||
}
|
||||
|
||||
var params map[string]any
|
||||
if rawParams, ok := chunk.Metadata["contentParams"].(map[string]any); ok {
|
||||
params = rawParams
|
||||
}
|
||||
chunk.Content = a.appText(contentKey, params)
|
||||
return chunk
|
||||
}
|
||||
|
||||
func diagnosticRiskLevel(commandType string) string {
|
||||
switch strings.TrimSpace(commandType) {
|
||||
case jvm.DiagnosticCommandCategoryObserve:
|
||||
@@ -272,6 +293,20 @@ func diagnosticRiskLevel(commandType string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func isDiagnosticCanceledError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var localized *jvm.LocalizedError
|
||||
if errors.As(err, &localized) && localized != nil {
|
||||
switch strings.TrimSpace(localized.Key) {
|
||||
case "jvm.backend.diagnostic.arthas.command_canceled":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "canceled")
|
||||
}
|
||||
|
||||
func isDiagnosticTerminalPhase(phase string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(phase)) {
|
||||
case "completed", "failed", "canceled":
|
||||
@@ -281,7 +316,7 @@ func isDiagnosticTerminalPhase(phase string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func joinDiagnosticMessages(primary string, warnings []string) string {
|
||||
func (a *App) joinDiagnosticMessages(primary string, warnings []string) string {
|
||||
items := make([]string, 0, 1+len(warnings))
|
||||
if strings.TrimSpace(primary) != "" {
|
||||
items = append(items, strings.TrimSpace(primary))
|
||||
@@ -292,5 +327,5 @@ func joinDiagnosticMessages(primary string, warnings []string) string {
|
||||
}
|
||||
items = append(items, strings.TrimSpace(warning))
|
||||
}
|
||||
return strings.Join(items, ";")
|
||||
return strings.Join(items, a.appText("jvm.backend.separator.message_warning", nil))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type fakeDiagnosticTransport struct {
|
||||
startErr error
|
||||
executeReq jvm.DiagnosticCommandRequest
|
||||
executeErr error
|
||||
executeHook func()
|
||||
executeCalls int
|
||||
cancelSession string
|
||||
cancelCommand string
|
||||
@@ -41,6 +42,9 @@ func (f fakeDiagnosticTransport) StartSession(context.Context, connection.Connec
|
||||
}
|
||||
|
||||
func (f fakeDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
|
||||
if f.executeHook != nil {
|
||||
f.executeHook()
|
||||
}
|
||||
return f.executeErr
|
||||
}
|
||||
|
||||
@@ -101,6 +105,25 @@ func (f *fakeStreamingDiagnosticTransport) CloseSession(context.Context, connect
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureJVMDiagnosticChunks(t *testing.T, app *App) (*[]diagnosticChunkEventPayload, func()) {
|
||||
t.Helper()
|
||||
|
||||
app.ctx = context.Background()
|
||||
emitted := make([]diagnosticChunkEventPayload, 0, 2)
|
||||
prevEmitter := emitJVMDiagnosticRuntimeEvent
|
||||
emitJVMDiagnosticRuntimeEvent = func(ctx context.Context, eventName string, optionalData ...interface{}) {
|
||||
if eventName != diagnosticChunkEvent {
|
||||
return
|
||||
}
|
||||
payload, ok := optionalData[0].(diagnosticChunkEventPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected diagnostic chunk event payload, got %#v", optionalData[0])
|
||||
}
|
||||
emitted = append(emitted, payload)
|
||||
}
|
||||
return &emitted, func() { emitJVMDiagnosticRuntimeEvent = prevEmitter }
|
||||
}
|
||||
|
||||
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
@@ -178,6 +201,365 @@ func TestJVMStartDiagnosticSessionReturnsHandle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMDiagnosticStaticMessagesUseEnglishAppText(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
disabledRes := app.JVMProbeDiagnosticCapabilities(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
if disabledRes.Success || disabledRes.Message != "JVM diagnostic enhancement is not enabled for this connection" {
|
||||
t.Fatalf("expected localized disabled message, got %+v", disabledRes)
|
||||
}
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sessionRes := app.JVMExecuteDiagnosticCommand(cfg, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
Command: "thread -n 5",
|
||||
})
|
||||
if sessionRes.Success || sessionRes.Message != "Diagnostic session ID is required. Create a session first." {
|
||||
t.Fatalf("expected localized session required message, got %+v", sessionRes)
|
||||
}
|
||||
|
||||
commandRes := app.JVMExecuteDiagnosticCommand(cfg, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
})
|
||||
if commandRes.Success || commandRes.Message != "Diagnostic command cannot be empty" {
|
||||
t.Fatalf("expected localized command required message, got %+v", commandRes)
|
||||
}
|
||||
|
||||
cancelRes := app.JVMCancelDiagnosticCommand(cfg, "tab-diag-1", "sess-1", " ")
|
||||
if cancelRes.Success || cancelRes.Message != "Cancel command requires sessionId and commandId" {
|
||||
t.Fatalf("expected localized cancel identifiers message, got %+v", cancelRes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandLocalizesAuditWriteBlockedWithRawDetail(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
blockerPath := filepath.Join(tempDir, "audit-blocker")
|
||||
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
app.configDir = blockerPath
|
||||
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return diagnosticTransportRecorder{recorder: recorder}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-audit",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "observe threads",
|
||||
})
|
||||
|
||||
expectedPrefix := "Failed to write diagnostic audit record, command execution was blocked: "
|
||||
if res.Success || !strings.HasPrefix(res.Message, expectedPrefix) {
|
||||
t.Fatalf("expected localized audit blocked message, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, blockerPath) {
|
||||
t.Fatalf("expected raw audit error detail to include %q, got %q", blockerPath, res.Message)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandLocalizesAuditWarningWithEnglishSeparatorAndRawErrors(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
app.configDir = filepath.Join(tempDir, "audit-root")
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{
|
||||
executeErr: errors.New("bridge exploded"),
|
||||
executeHook: func() {
|
||||
if err := os.RemoveAll(app.configDir); err != nil {
|
||||
t.Fatalf("RemoveAll returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(app.configDir, []byte("terminal-blocker"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-observe-warning",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "observe threads",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected execute failure, got %+v", res)
|
||||
}
|
||||
if !strings.HasPrefix(res.Message, "bridge exploded; Failed to write audit record: ") {
|
||||
t.Fatalf("expected raw execute error and localized warning joined by English separator, got %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, app.configDir) {
|
||||
t.Fatalf("expected raw audit warning detail to include %q, got %q", app.configDir, res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandLocalizesTransportLocalizedError(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
|
||||
defer restoreEmitter()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{
|
||||
executeErr: &jvm.LocalizedError{
|
||||
Key: "jvm.backend.diagnostic.arthas.base_url_invalid",
|
||||
Params: map[string]any{"detail": "://raw-bad-url"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-localized-error",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "observe threads",
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected execute failure, got %+v", res)
|
||||
}
|
||||
if res.Message != "Arthas Tunnel address is invalid: ://raw-bad-url" {
|
||||
t.Fatalf("expected localized transport error with raw detail, got %q", res.Message)
|
||||
}
|
||||
if strings.Contains(res.Message, "jvm.backend.diagnostic.arthas.base_url_invalid") {
|
||||
t.Fatalf("expected user message not to expose i18n key, got %q", res.Message)
|
||||
}
|
||||
if len(*emitted) != 1 {
|
||||
t.Fatalf("expected one failed chunk, got %#v", *emitted)
|
||||
}
|
||||
if (*emitted)[0].Chunk.Content != res.Message {
|
||||
t.Fatalf("expected failed chunk content to match localized message, got %#v", (*emitted)[0].Chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandLocalizesChunkContentKeyAndKeepsRunningOutputRaw(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
|
||||
defer restoreEmitter()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return &fakeStreamingDiagnosticTransport{
|
||||
chunks: []jvm.DiagnosticEventChunk{
|
||||
{
|
||||
Event: "diagnostic",
|
||||
Phase: "running",
|
||||
Content: "Arthas raw output: 中文 and targetId=orders-prod-01",
|
||||
},
|
||||
{
|
||||
Event: "diagnostic",
|
||||
Phase: "completed",
|
||||
Content: "",
|
||||
Metadata: map[string]any{
|
||||
"transport": jvm.DiagnosticTransportArthasTunnel,
|
||||
"contentKey": "jvm.backend.diagnostic.message.arthas_command_completed",
|
||||
"rawTargetID": "orders-prod-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-content-key",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "observe threads",
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got %+v", res)
|
||||
}
|
||||
if len(*emitted) != 2 {
|
||||
t.Fatalf("expected running and completed chunks, got %#v", *emitted)
|
||||
}
|
||||
if (*emitted)[0].Chunk.Content != "Arthas raw output: 中文 and targetId=orders-prod-01" {
|
||||
t.Fatalf("expected running output to remain raw, got %#v", (*emitted)[0].Chunk)
|
||||
}
|
||||
completed := (*emitted)[1].Chunk
|
||||
if completed.Content != "Arthas command completed" {
|
||||
t.Fatalf("expected contentKey-localized completed content, got %#v", completed)
|
||||
}
|
||||
if completed.Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_completed" {
|
||||
t.Fatalf("expected contentKey metadata to be preserved, got %#v", completed.Metadata)
|
||||
}
|
||||
if completed.Metadata["rawTargetID"] != "orders-prod-01" {
|
||||
t.Fatalf("expected raw metadata to be preserved, got %#v", completed.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandEmitsEnglishCompletedChunk(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
|
||||
defer restoreEmitter()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
|
||||
ID: "conn-orders",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
AllowObserveCommands: true,
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
|
||||
SessionID: "sess-1",
|
||||
CommandID: "cmd-completed",
|
||||
Command: "thread -n 5",
|
||||
Source: "manual",
|
||||
Reason: "observe threads",
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got %+v", res)
|
||||
}
|
||||
if len(*emitted) != 1 {
|
||||
t.Fatalf("expected one completed chunk, got %#v", *emitted)
|
||||
}
|
||||
if (*emitted)[0].Chunk.Content != "Diagnostic command completed" {
|
||||
t.Fatalf("expected localized completed chunk content, got %#v", (*emitted)[0].Chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMCancelDiagnosticCommandEmitsEnglishCancelChunk(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
|
||||
defer restoreEmitter()
|
||||
|
||||
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
|
||||
return fakeDiagnosticTransport{}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMCancelDiagnosticCommand(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: jvm.DiagnosticTransportAgentBridge,
|
||||
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
|
||||
},
|
||||
},
|
||||
}, "tab-diag-1", "sess-1", "cmd-1")
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got %+v", res)
|
||||
}
|
||||
if len(*emitted) != 1 {
|
||||
t.Fatalf("expected one cancel chunk, got %#v", *emitted)
|
||||
}
|
||||
if (*emitted)[0].Chunk.Content != "Cancel request sent; waiting for the diagnostic bridge to stop the command" {
|
||||
t.Fatalf("expected localized cancel chunk content, got %#v", (*emitted)[0].Chunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
recorder := &fakeDiagnosticTransport{}
|
||||
@@ -248,7 +630,7 @@ func TestJVMExecuteDiagnosticCommandBlocksTraceWhenConnectionReadOnly(t *testing
|
||||
if res.Success {
|
||||
t.Fatalf("expected trace command to be blocked in read-only mode, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "只读") {
|
||||
if !strings.Contains(res.Message, "read-only") {
|
||||
t.Fatalf("expected read-only message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
@@ -290,7 +672,7 @@ func TestJVMExecuteDiagnosticCommandBlocksMutatingWhenConnectionReadOnly(t *test
|
||||
if res.Success {
|
||||
t.Fatalf("expected mutating command to be blocked in read-only mode, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "只读") {
|
||||
if !strings.Contains(res.Message, "read-only") {
|
||||
t.Fatalf("expected read-only message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
@@ -617,7 +999,7 @@ func TestJVMExecuteDiagnosticCommandFailsClosedWhenAuditWriteFails(t *testing.T)
|
||||
if res.Success {
|
||||
t.Fatalf("expected command to fail closed when initial audit write fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "审计") {
|
||||
if !strings.Contains(res.Message, "audit") {
|
||||
t.Fatalf("expected audit failure message, got %+v", res)
|
||||
}
|
||||
if recorder.executeCalls != 0 {
|
||||
|
||||
@@ -20,32 +20,32 @@ var currentJVMMonitoringManager jvmMonitoringService = jvm.NewMonitoringManager(
|
||||
func (a *App) JVMStartMonitoring(cfg connection.ConnectionConfig) connection.QueryResult {
|
||||
snapshot, err := currentJVMMonitoringManager.Start(a.ctx, cfg, "")
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: snapshot}
|
||||
return connection.QueryResult{Success: true, Data: a.localizeJVMMonitoringSnapshot(snapshot)}
|
||||
}
|
||||
|
||||
func (a *App) JVMGetMonitoringHistory(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
|
||||
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
snapshot, err := currentJVMMonitoringManager.GetHistory(connectionID, resolvedMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: snapshot}
|
||||
return connection.QueryResult{Success: true, Data: a.localizeJVMMonitoringSnapshot(snapshot)}
|
||||
}
|
||||
|
||||
func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
|
||||
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
|
||||
if err := currentJVMMonitoringManager.Stop(connectionID, resolvedMode); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]any{
|
||||
"connectionId": connectionID,
|
||||
@@ -54,6 +54,23 @@ func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode st
|
||||
}}
|
||||
}
|
||||
|
||||
func (a *App) localizeJVMMonitoringSnapshot(snapshot jvm.MonitoringSessionSnapshot) jvm.MonitoringSessionSnapshot {
|
||||
if len(snapshot.ProviderWarnings) == 0 {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
warnings := append([]string(nil), snapshot.ProviderWarnings...)
|
||||
for index, warning := range warnings {
|
||||
key, params, ok := jvm.ParseMonitoringProviderWarning(warning)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
warnings[index] = a.appText(key, params)
|
||||
}
|
||||
snapshot.ProviderWarnings = warnings
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func resolveJVMMonitoringLookup(cfg connection.ConnectionConfig, requestedMode string) (string, string, error) {
|
||||
normalized, resolvedMode, err := jvm.ResolveProviderMode(cfg, requestedMode)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -145,3 +146,165 @@ func TestJVMStopMonitoringReturnsManagerError(t *testing.T) {
|
||||
t.Fatalf("unexpected manager stop args: connection=%q mode=%q", manager.stopConnection, manager.stopMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMMonitoringMethodsLocalizeManagerLocalizedErrors(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
manager := &fakeJVMMonitoringManager{
|
||||
startErr: &jvm.LocalizedError{
|
||||
Key: "jvm.backend.monitoring.error.snapshot_unsupported",
|
||||
Params: map[string]any{
|
||||
"provider": "JMX",
|
||||
},
|
||||
},
|
||||
historyErr: &jvm.LocalizedError{
|
||||
Key: "jvm.backend.monitoring.error.session_not_found",
|
||||
Params: map[string]any{
|
||||
"connectionId": "conn-history",
|
||||
"providerMode": jvm.ModeJMX,
|
||||
},
|
||||
},
|
||||
stopErr: &jvm.LocalizedError{
|
||||
Key: "jvm.backend.monitoring.error.session_not_found",
|
||||
Params: map[string]any{
|
||||
"connectionId": "conn-stop",
|
||||
"providerMode": jvm.ModeAgent,
|
||||
},
|
||||
},
|
||||
}
|
||||
restore := swapJVMMonitoringManager(manager)
|
||||
defer restore()
|
||||
|
||||
startRes := app.JVMStartMonitoring(connection.ConnectionConfig{
|
||||
ID: "conn-monitor",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: jvm.ModeJMX,
|
||||
AllowedModes: []string{jvm.ModeJMX},
|
||||
},
|
||||
})
|
||||
assertMonitoringEnglishMessage(t, startRes, "JMX monitoring snapshot is not supported yet")
|
||||
|
||||
historyRes := app.JVMGetMonitoringHistory(connection.ConnectionConfig{
|
||||
ID: "conn-history",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: jvm.ModeJMX,
|
||||
AllowedModes: []string{jvm.ModeJMX},
|
||||
},
|
||||
}, "")
|
||||
assertMonitoringEnglishMessage(t, historyRes, "Monitoring session not found for conn-history jmx")
|
||||
|
||||
stopRes := app.JVMStopMonitoring(connection.ConnectionConfig{
|
||||
ID: "conn-stop",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: jvm.ModeAgent,
|
||||
AllowedModes: []string{jvm.ModeAgent},
|
||||
},
|
||||
}, "")
|
||||
assertMonitoringEnglishMessage(t, stopRes, "Monitoring session not found for conn-stop agent")
|
||||
}
|
||||
|
||||
func TestJVMMonitoringMethodsLocalizeStructuredProviderWarnings(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
manager := &fakeJVMMonitoringManager{
|
||||
startSnapshot: jvm.MonitoringSessionSnapshot{
|
||||
ConnectionID: "conn-monitor",
|
||||
ProviderMode: jvm.ModeJMX,
|
||||
Running: false,
|
||||
ProviderWarnings: []string{
|
||||
"endpoint cpu metric unavailable",
|
||||
"__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3",
|
||||
},
|
||||
},
|
||||
historySnapshot: jvm.MonitoringSessionSnapshot{
|
||||
ConnectionID: "conn-monitor",
|
||||
ProviderMode: jvm.ModeJMX,
|
||||
Running: false,
|
||||
ProviderWarnings: []string{
|
||||
"__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3",
|
||||
"collector returned HTTP 503",
|
||||
},
|
||||
},
|
||||
}
|
||||
restore := swapJVMMonitoringManager(manager)
|
||||
defer restore()
|
||||
|
||||
startRes := app.JVMStartMonitoring(connection.ConnectionConfig{
|
||||
ID: "conn-monitor",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: jvm.ModeJMX,
|
||||
AllowedModes: []string{jvm.ModeJMX},
|
||||
},
|
||||
})
|
||||
startSnapshot := assertMonitoringSnapshot(t, startRes)
|
||||
assertMonitoringWarnings(t, startSnapshot.ProviderWarnings, []string{
|
||||
"endpoint cpu metric unavailable",
|
||||
"Monitoring sampling failed 3 consecutive times and this session was stopped automatically",
|
||||
})
|
||||
|
||||
historyRes := app.JVMGetMonitoringHistory(connection.ConnectionConfig{
|
||||
ID: "conn-monitor",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: jvm.ModeJMX,
|
||||
AllowedModes: []string{jvm.ModeJMX},
|
||||
},
|
||||
}, "")
|
||||
historySnapshot := assertMonitoringSnapshot(t, historyRes)
|
||||
assertMonitoringWarnings(t, historySnapshot.ProviderWarnings, []string{
|
||||
"Monitoring sampling failed 3 consecutive times and this session was stopped automatically",
|
||||
"collector returned HTTP 503",
|
||||
})
|
||||
}
|
||||
|
||||
func assertMonitoringEnglishMessage(t *testing.T, res connection.QueryResult, want string) {
|
||||
t.Helper()
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected monitoring method to fail, got %+v", res)
|
||||
}
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected monitoring message %q, got %+v", want, res)
|
||||
}
|
||||
if strings.Contains(res.Message, "jvm.backend.") {
|
||||
t.Fatalf("expected localized message instead of raw key, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMonitoringSnapshot(t *testing.T, res connection.QueryResult) jvm.MonitoringSessionSnapshot {
|
||||
t.Helper()
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected monitoring method to succeed, got %+v", res)
|
||||
}
|
||||
snapshot, ok := res.Data.(jvm.MonitoringSessionSnapshot)
|
||||
if !ok {
|
||||
t.Fatalf("expected monitoring snapshot, got %#v", res.Data)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func assertMonitoringWarnings(t *testing.T, got []string, want []string) {
|
||||
t.Helper()
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected warnings %#v, got %#v", want, got)
|
||||
}
|
||||
for index, expected := range want {
|
||||
if got[index] != expected {
|
||||
t.Fatalf("expected warning %d to be %q, got %#v", index, expected, got)
|
||||
}
|
||||
if strings.Contains(got[index], "jvm.backend.") {
|
||||
t.Fatalf("expected localized warning instead of raw key, got %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,40 @@ func forceAuditAppendFailureAfterPending(t *testing.T, auditDir string) {
|
||||
}
|
||||
}
|
||||
|
||||
func expectedAuditAppendError(t *testing.T, auditRoot string) string {
|
||||
t.Helper()
|
||||
|
||||
err := jvm.NewAuditStore(filepath.Join(auditRoot, "jvm_audit.jsonl")).Append(jvm.AuditRecord{
|
||||
Timestamp: 1,
|
||||
ConnectionID: "conn-orders",
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "expected raw audit append error",
|
||||
Result: "pending",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected audit append to fail for %q", auditRoot)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func assertEnglishJVMConfirmationMessage(t *testing.T, got string, want string) {
|
||||
t.Helper()
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("expected English confirmation message %q, got %q", want, got)
|
||||
}
|
||||
for _, text := range []string{"确认", "令牌", "预览", "重新"} {
|
||||
if strings.Contains(got, text) {
|
||||
t.Fatalf("expected no Chinese confirmation text %q in message %q", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
var gotMode string
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
gotMode = mode
|
||||
@@ -109,8 +141,8 @@ func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) {
|
||||
if gotMode != "endpoint" {
|
||||
t.Fatalf("expected provider mode endpoint, got %q", gotMode)
|
||||
}
|
||||
if res.Message != "JVM 连接成功" {
|
||||
t.Fatalf("expected success message %q, got %q", "JVM 连接成功", res.Message)
|
||||
if res.Message != "JVM connection succeeded" {
|
||||
t.Fatalf("expected success message %q, got %q", "JVM connection succeeded", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +309,95 @@ func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMProbeCapabilitiesLocalizesBuiltInReadOnlyReasons(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
restore := swapJVMProviderFactory(jvm.NewProvider)
|
||||
defer restore()
|
||||
|
||||
readOnly := true
|
||||
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
Timeout: 3,
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx", "endpoint", "agent"},
|
||||
JMX: connection.JVMJMXConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 9010,
|
||||
},
|
||||
Endpoint: connection.JVMEndpointConfig{
|
||||
BaseURL: "https://orders.internal/manage/jvm",
|
||||
TimeoutSeconds: 3,
|
||||
},
|
||||
Agent: connection.JVMAgentConfig{
|
||||
BaseURL: "https://orders.internal/agent",
|
||||
TimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got %+v", res)
|
||||
}
|
||||
items, ok := res.Data.([]jvm.Capability)
|
||||
if !ok || len(items) != 3 {
|
||||
t.Fatalf("expected three capabilities, got %#v", res.Data)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.CanWrite {
|
||||
t.Fatalf("expected read-only capability for %s, got %#v", item.Mode, item)
|
||||
}
|
||||
if item.Reason != "Current connection is read-only, so writes are blocked" {
|
||||
t.Fatalf("expected localized read-only reason for %s, got %#v", item.Mode, item)
|
||||
}
|
||||
if strings.Contains(item.Reason, "jvm.backend.") || strings.Contains(item.Reason, "只读") {
|
||||
t.Fatalf("expected no key or Chinese text in reason for %s, got %q", item.Mode, item.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMProbeCapabilitiesKeepsProviderReasonThatLooksLikeCatalogKeyRaw(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
providerReason := "jvm.backend.error.change_blocked_read_only"
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
probe: []jvm.Capability{{
|
||||
Mode: jvm.ModeJMX,
|
||||
CanBrowse: true,
|
||||
CanWrite: false,
|
||||
CanPreview: true,
|
||||
DisplayLabel: "JMX",
|
||||
Reason: providerReason,
|
||||
}},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected success, got %+v", res)
|
||||
}
|
||||
items, ok := res.Data.([]jvm.Capability)
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("expected one capability, got %#v", res.Data)
|
||||
}
|
||||
if items[0].Reason != providerReason {
|
||||
t.Fatalf("expected provider reason to stay raw, got %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMProbeCapabilitiesTranslatesJMXProbeErrorUsingCurrentMode(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
@@ -480,6 +601,7 @@ func TestJVMGetValueReturnsProviderPayload(t *testing.T) {
|
||||
|
||||
func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := false
|
||||
var applyReq jvm.ChangeRequest
|
||||
@@ -529,9 +651,7 @@ func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T)
|
||||
if res.Success {
|
||||
t.Fatalf("expected missing confirmation token to fail, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "确认") && !strings.Contains(res.Message, "重新预览") {
|
||||
t.Fatalf("expected confirmation guidance message, got %q", res.Message)
|
||||
}
|
||||
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is missing. Complete preview confirmation first.")
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
}
|
||||
@@ -601,6 +721,212 @@ func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeUsesEnglishGuardFallbackWhenBlockingReasonEmpty(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := false
|
||||
var applyReq jvm.ChangeRequest
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
value: jvm.ValueSnapshot{
|
||||
ResourceID: "/cache/orders",
|
||||
Kind: "entry",
|
||||
Format: "json",
|
||||
},
|
||||
previewSet: true,
|
||||
preview: jvm.ChangePreview{
|
||||
Allowed: false,
|
||||
},
|
||||
applyReq: &applyReq,
|
||||
apply: jvm.ApplyResult{Status: "applied"},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMApplyChange(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{"status": "ready"},
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected guard-blocked apply to fail, got %+v", res)
|
||||
}
|
||||
if res.Message != "The current change was blocked by Guard" {
|
||||
t.Fatalf("expected English guard fallback message, got %q", res.Message)
|
||||
}
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMProviderBlockingReasonThatLooksLikeCatalogKeyStaysRaw(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := false
|
||||
providerReason := "jvm.backend.error.change_blocked_read_only"
|
||||
var applyReq jvm.ChangeRequest
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
previewSet: true,
|
||||
preview: jvm.ChangePreview{
|
||||
Allowed: false,
|
||||
BlockingReason: providerReason,
|
||||
},
|
||||
applyReq: &applyReq,
|
||||
apply: jvm.ApplyResult{Status: "applied"},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-provider-reason",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}
|
||||
req := jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{"status": "ready"},
|
||||
}
|
||||
|
||||
previewRes := app.JVMPreviewChange(cfg, req)
|
||||
if !previewRes.Success {
|
||||
t.Fatalf("expected blocked preview result, got %+v", previewRes)
|
||||
}
|
||||
preview, ok := previewRes.Data.(jvm.ChangePreview)
|
||||
if !ok {
|
||||
t.Fatalf("expected preview data, got %#v", previewRes.Data)
|
||||
}
|
||||
if preview.BlockingReason != providerReason {
|
||||
t.Fatalf("expected provider blocking reason to stay raw, got %q", preview.BlockingReason)
|
||||
}
|
||||
|
||||
applyRes := app.JVMApplyChange(cfg, req)
|
||||
if applyRes.Success {
|
||||
t.Fatalf("expected provider-blocked apply to fail, got %+v", applyRes)
|
||||
}
|
||||
if applyRes.Message != providerReason {
|
||||
t.Fatalf("expected provider blocking message to stay raw, got %q", applyRes.Message)
|
||||
}
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMPreviewChange(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
readOnly := true
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMPreviewChange(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-readonly",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{"status": "ready"},
|
||||
})
|
||||
|
||||
if !res.Success {
|
||||
t.Fatalf("expected read-only preview to return blocked preview, got %+v", res)
|
||||
}
|
||||
preview, ok := res.Data.(jvm.ChangePreview)
|
||||
if !ok {
|
||||
t.Fatalf("expected preview data, got %#v", res.Data)
|
||||
}
|
||||
if preview.Allowed {
|
||||
t.Fatalf("expected preview to be blocked, got %#v", preview)
|
||||
}
|
||||
if preview.BlockingReason != "Current connection is read-only, so writes are blocked" {
|
||||
t.Fatalf("expected localized read-only blocking reason, got %#v", preview)
|
||||
}
|
||||
if strings.Contains(preview.BlockingReason, "jvm.backend.") || strings.Contains(preview.BlockingReason, "只读") {
|
||||
t.Fatalf("expected app boundary to localize blocking reason, got %q", preview.BlockingReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChange(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := true
|
||||
var applyReq jvm.ChangeRequest
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
applyReq: &applyReq,
|
||||
apply: jvm.ApplyResult{Status: "applied"},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMApplyChange(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-readonly",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{"status": "ready"},
|
||||
})
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected read-only apply to fail, got %+v", res)
|
||||
}
|
||||
if res.Message != "Current connection is read-only, so writes are blocked" {
|
||||
t.Fatalf("expected localized read-only blocking reason, got %q", res.Message)
|
||||
}
|
||||
if strings.Contains(res.Message, "jvm.backend.") || strings.Contains(res.Message, "只读") {
|
||||
t.Fatalf("expected app boundary to localize blocking reason, got %q", res.Message)
|
||||
}
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
@@ -700,8 +1026,153 @@ func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueJVMPreviewConfirmationTokenLocalizesPayloadHashError(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
readOnly := false
|
||||
|
||||
_, err := app.issueJVMPreviewConfirmationToken(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Environment: jvm.EnvPROD,
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{
|
||||
"callback": func() {},
|
||||
},
|
||||
}, jvm.ChangePreview{
|
||||
Allowed: true,
|
||||
RequiresConfirmation: true,
|
||||
ConfirmationToken: "preview-token",
|
||||
Summary: "risky change",
|
||||
RiskLevel: "high",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected payload hash error")
|
||||
}
|
||||
|
||||
got := err.Error()
|
||||
want := "Failed to generate JVM preview payload digest: json: unsupported type: func()"
|
||||
if got != want {
|
||||
t.Fatalf("expected localized payload hash error %q, got %q", want, got)
|
||||
}
|
||||
if strings.Contains(got, "生成 JVM 预览载荷摘要失败") {
|
||||
t.Fatalf("expected no Chinese payload hash prefix in message %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMPreviewChangeLocalizesConfirmationTokenFailure(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
readOnly := false
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
previewSet: true,
|
||||
preview: jvm.ChangePreview{
|
||||
Allowed: true,
|
||||
RequiresConfirmation: true,
|
||||
Summary: "risky change",
|
||||
RiskLevel: "high",
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMPreviewChange(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Environment: jvm.EnvPROD,
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{
|
||||
"callback": func() {},
|
||||
},
|
||||
})
|
||||
if res.Success {
|
||||
t.Fatalf("expected preview failure, got %+v", res)
|
||||
}
|
||||
|
||||
want := "Failed to generate JVM change confirmation token: json: unsupported type: func()"
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected localized confirmation token error %q, got %q", want, res.Message)
|
||||
}
|
||||
if strings.Contains(res.Message, "生成 JVM 变更确认令牌失败") {
|
||||
t.Fatalf("expected no Chinese confirmation token prefix in message %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeLocalizesConfirmationTokenFailure(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
readOnly := false
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
previewSet: true,
|
||||
preview: jvm.ChangePreview{
|
||||
Allowed: true,
|
||||
RequiresConfirmation: true,
|
||||
Summary: "risky change",
|
||||
RiskLevel: "high",
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res := app.JVMApplyChange(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Environment: jvm.EnvPROD,
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{
|
||||
"callback": func() {},
|
||||
},
|
||||
})
|
||||
if res.Success {
|
||||
t.Fatalf("expected apply preview failure, got %+v", res)
|
||||
}
|
||||
|
||||
want := "Failed to generate JVM change confirmation token: json: unsupported type: func()"
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected localized confirmation token error %q, got %q", want, res.Message)
|
||||
}
|
||||
if strings.Contains(res.Message, "生成 JVM 变更确认令牌失败") {
|
||||
t.Fatalf("expected no Chinese confirmation token prefix in message %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := false
|
||||
var applyReq jvm.ChangeRequest
|
||||
@@ -766,11 +1237,86 @@ func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.
|
||||
if res.Success {
|
||||
t.Fatalf("expected unissued confirmation token to fail, got %+v", res)
|
||||
}
|
||||
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is invalid. Preview and confirm again.")
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeRejectsMismatchedPreviewConfirmationContext(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
readOnly := false
|
||||
applyCalls := 0
|
||||
|
||||
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
|
||||
return fakeJVMProvider{
|
||||
value: jvm.ValueSnapshot{
|
||||
ResourceID: "/cache/orders",
|
||||
Kind: "entry",
|
||||
Format: "json",
|
||||
Value: map[string]any{
|
||||
"status": "stale",
|
||||
},
|
||||
},
|
||||
previewSet: true,
|
||||
preview: jvm.ChangePreview{
|
||||
Allowed: true,
|
||||
RequiresConfirmation: true,
|
||||
Summary: "risky change",
|
||||
RiskLevel: "high",
|
||||
},
|
||||
applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) {
|
||||
applyCalls++
|
||||
return jvm.ApplyResult{Status: "applied"}, nil
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
Environment: jvm.EnvPROD,
|
||||
ReadOnly: &readOnly,
|
||||
PreferredMode: "jmx",
|
||||
AllowedModes: []string{"jmx"},
|
||||
},
|
||||
}
|
||||
req := jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
Payload: map[string]any{
|
||||
"status": "ready",
|
||||
},
|
||||
}
|
||||
|
||||
previewRes := app.JVMPreviewChange(cfg, req)
|
||||
if !previewRes.Success {
|
||||
t.Fatalf("expected preview success, got %+v", previewRes)
|
||||
}
|
||||
preview, ok := previewRes.Data.(jvm.ChangePreview)
|
||||
if !ok {
|
||||
t.Fatalf("expected preview payload, got %#v", previewRes.Data)
|
||||
}
|
||||
|
||||
req.ConfirmationToken = preview.ConfirmationToken
|
||||
req.ResourceID = "/cache/customers"
|
||||
res := app.JVMApplyChange(cfg, req)
|
||||
if res.Success {
|
||||
t.Fatalf("expected mismatched confirmation context to fail, got %+v", res)
|
||||
}
|
||||
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is invalid. Preview and confirm again.")
|
||||
if applyCalls != 0 {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.configDir = t.TempDir()
|
||||
@@ -848,6 +1394,7 @@ func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) {
|
||||
|
||||
func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
app.configDir = t.TempDir()
|
||||
app.jvmPreviewTokenTTL = time.Nanosecond
|
||||
readOnly := false
|
||||
@@ -914,6 +1461,7 @@ func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) {
|
||||
if res.Success {
|
||||
t.Fatalf("expected expired confirmation token to fail, got %+v", res)
|
||||
}
|
||||
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token expired. Preview and confirm again.")
|
||||
if applyCalls != 0 {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls)
|
||||
}
|
||||
@@ -1074,10 +1622,11 @@ func TestJVMApplyChangeNormalizesRequestBeforeProviderAndAudit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
|
||||
func TestJVMPreviewChangeRejectsDisallowedProviderMode(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
|
||||
res := app.JVMPreviewChange(connection.ConnectionConfig{
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
ID: "conn-orders",
|
||||
Host: "orders.internal",
|
||||
@@ -1085,18 +1634,33 @@ func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
|
||||
PreferredMode: "endpoint",
|
||||
AllowedModes: []string{"endpoint"},
|
||||
},
|
||||
}, jvm.ChangeRequest{
|
||||
}
|
||||
req := jvm.ChangeRequest{
|
||||
ProviderMode: "jmx",
|
||||
ResourceID: "/cache/orders",
|
||||
Action: "put",
|
||||
Reason: "repair cache",
|
||||
})
|
||||
}
|
||||
|
||||
res := app.JVMPreviewChange(cfg, req)
|
||||
|
||||
if res.Success {
|
||||
t.Fatalf("expected preview request to be rejected, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "不允许使用") {
|
||||
t.Fatalf("expected disallowed mode error, got %+v", res)
|
||||
want := "Current connection does not allow jmx mode"
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected localized disallowed mode error %q, got %+v", want, res)
|
||||
}
|
||||
if strings.Contains(res.Message, "不允许使用") || strings.Contains(res.Message, "jvm.backend.") {
|
||||
t.Fatalf("expected no Chinese or raw key in disallowed mode error, got %q", res.Message)
|
||||
}
|
||||
|
||||
applyRes := app.JVMApplyChange(cfg, req)
|
||||
if applyRes.Success {
|
||||
t.Fatalf("expected apply request to be rejected, got %+v", applyRes)
|
||||
}
|
||||
if applyRes.Message != want {
|
||||
t.Fatalf("expected localized apply disallowed mode error %q, got %+v", want, applyRes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1132,12 +1696,14 @@ func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) {
|
||||
|
||||
func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
blockerPath := filepath.Join(tempDir, "audit-blocker")
|
||||
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
app.configDir = blockerPath
|
||||
expectedDetail := expectedAuditAppendError(t, blockerPath)
|
||||
|
||||
readOnly := false
|
||||
var applyReq jvm.ChangeRequest
|
||||
@@ -1181,8 +1747,9 @@ func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) {
|
||||
if res.Success {
|
||||
t.Fatalf("expected fail-closed when initial audit write fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "审计") {
|
||||
t.Fatalf("expected audit failure message, got %q", res.Message)
|
||||
want := "Failed to write audit record, JVM change was blocked: " + expectedDetail
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected English audit failure message %q, got %q", want, res.Message)
|
||||
}
|
||||
if applyReq.ResourceID != "" {
|
||||
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
|
||||
@@ -1246,6 +1813,7 @@ func TestJVMApplyChangeLatestAuditRecordIsTerminal(t *testing.T) {
|
||||
|
||||
func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1302,13 +1870,16 @@ func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing
|
||||
if result.Status != "applied" {
|
||||
t.Fatalf("expected applied status, got %#v", result)
|
||||
}
|
||||
if !strings.Contains(result.Message, "终态审计写入失败") {
|
||||
t.Fatalf("expected terminal audit warning in result message, got %#v", result)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
want := "ok; Failed to write terminal audit record: " + expectedDetail
|
||||
if result.Message != want {
|
||||
t.Fatalf("expected terminal audit warning %q, got %#v", want, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1358,11 +1929,10 @@ func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) {
|
||||
if res.Success {
|
||||
t.Fatalf("expected failure when apply fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "provider apply failed") {
|
||||
t.Fatalf("expected provider failure in message, got %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, "失败审计写入失败") {
|
||||
t.Fatalf("expected failed audit write failure in message, got %q", res.Message)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
want := "provider apply failed; Failed to write failure audit record: " + expectedDetail
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected provider failure with failed audit warning %q, got %q", want, res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1412,6 +1982,7 @@ func TestJVMApplyChangeApplyFailureKeepsProviderErrorWhenFailedAuditSucceeds(t *
|
||||
|
||||
func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1457,16 +2028,16 @@ func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) {
|
||||
if res.Success {
|
||||
t.Fatalf("expected failure when provider apply fails, got %+v", res)
|
||||
}
|
||||
if !strings.HasPrefix(res.Message, "provider apply failed") {
|
||||
t.Fatalf("expected provider error prefix, got %q", res.Message)
|
||||
}
|
||||
if !strings.Contains(res.Message, "失败审计写入失败") {
|
||||
t.Fatalf("expected failed audit write failure in message, got %q", res.Message)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
want := "provider apply failed; Failed to write failure audit record: " + expectedDetail
|
||||
if res.Message != want {
|
||||
t.Fatalf("expected provider error with English failed audit warning %q, got %q", want, res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1509,13 +2080,16 @@ func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *tes
|
||||
if !ok {
|
||||
t.Fatalf("expected apply result data, got %#v", res.Data)
|
||||
}
|
||||
if !strings.Contains(result.Message, "provider message") || !strings.Contains(result.Message, "终态审计写入失败") {
|
||||
t.Fatalf("expected provider message with terminal audit warning, got %#v", result)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
want := "provider message; Failed to write terminal audit record: " + expectedDetail
|
||||
if result.Message != want {
|
||||
t.Fatalf("expected provider message with English terminal audit warning %q, got %#v", want, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessageEmpty(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1558,13 +2132,16 @@ func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessag
|
||||
if !ok {
|
||||
t.Fatalf("expected apply result data, got %#v", res.Data)
|
||||
}
|
||||
if !strings.Contains(result.Message, "终态审计写入失败") {
|
||||
t.Fatalf("expected standalone terminal audit warning, got %#v", result)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
want := "Failed to write terminal audit record: " + expectedDetail
|
||||
if result.Message != want {
|
||||
t.Fatalf("expected standalone English terminal audit warning %q, got %#v", want, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1599,17 +2176,15 @@ func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testi
|
||||
if res.Success {
|
||||
t.Fatalf("expected failure when apply fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, "失败审计写入失败") {
|
||||
t.Fatalf("expected failed audit failure marker, got %q", res.Message)
|
||||
}
|
||||
lowerMessage := strings.ToLower(res.Message)
|
||||
if !strings.Contains(lowerMessage, "not a directory") && !strings.Contains(lowerMessage, "system cannot find the path specified") {
|
||||
t.Fatalf("expected underlying audit failure detail in message, got %q", res.Message)
|
||||
expectedDetail := expectedAuditAppendError(t, auditDir)
|
||||
if !strings.Contains(res.Message, "Failed to write failure audit record: "+expectedDetail) {
|
||||
t.Fatalf("expected underlying audit failure detail %q in message, got %q", expectedDetail, res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T) {
|
||||
func TestJVMApplyChangeFailureMessageSeparatorUsesLocalizedEnglishSeparator(t *testing.T) {
|
||||
app := NewAppWithSecretStore(nil)
|
||||
app.SetLanguage("en-US")
|
||||
tempDir := t.TempDir()
|
||||
auditDir := filepath.Join(tempDir, "audit")
|
||||
if err := os.MkdirAll(auditDir, 0o755); err != nil {
|
||||
@@ -1644,8 +2219,11 @@ func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T)
|
||||
if res.Success {
|
||||
t.Fatalf("expected failure when apply fails, got %+v", res)
|
||||
}
|
||||
if !strings.Contains(res.Message, ";失败审计写入失败") {
|
||||
t.Fatalf("expected chinese semicolon separator in failure message, got %q", res.Message)
|
||||
if !strings.Contains(res.Message, "; Failed to write failure audit record: ") {
|
||||
t.Fatalf("expected English separator in failure message, got %q", res.Message)
|
||||
}
|
||||
if strings.Contains(res.Message, ";") {
|
||||
t.Fatalf("expected no Chinese semicolon separator in failure message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ func (p *AgentProvider) ProbeCapabilities(_ context.Context, cfg connection.Conn
|
||||
DisplayLabel: "Agent",
|
||||
Reason: func() string {
|
||||
if readOnly {
|
||||
return "当前连接只读"
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
reasonKey: func() string {
|
||||
if readOnly {
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
const defaultJMXPort = 9010
|
||||
const disallowedModeKey = "jvm.backend.error.disallowed_mode"
|
||||
|
||||
func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
cfg := raw
|
||||
@@ -51,7 +52,12 @@ func ResolveProviderMode(raw connection.ConnectionConfig, requestedMode string)
|
||||
selectedMode = cfg.JVM.PreferredMode
|
||||
}
|
||||
if !containsMode(cfg.JVM.AllowedModes, selectedMode) {
|
||||
return connection.ConnectionConfig{}, "", fmt.Errorf("当前连接不允许使用 %q 模式", selectedMode)
|
||||
return connection.ConnectionConfig{}, "", &LocalizedError{
|
||||
Key: disallowedModeKey,
|
||||
Params: map[string]any{
|
||||
"mode": selectedMode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cfg.JVM.PreferredMode = selectedMode
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -104,4 +105,14 @@ func TestResolveProviderModeRejectsDisallowedRequestedMode(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected disallowed requested mode to fail")
|
||||
}
|
||||
var localized *LocalizedError
|
||||
if !errors.As(err, &localized) {
|
||||
t.Fatalf("expected LocalizedError, got %T: %v", err, err)
|
||||
}
|
||||
if localized.Key != "jvm.backend.error.disallowed_mode" {
|
||||
t.Fatalf("expected disallowed mode key, got %q", localized.Key)
|
||||
}
|
||||
if localized.Params["mode"] != ModeJMX {
|
||||
t.Fatalf("expected raw mode param %q, got %#v", ModeJMX, localized.Params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -29,6 +28,40 @@ const (
|
||||
arthasTunnelMaxSessions = 128
|
||||
)
|
||||
|
||||
const (
|
||||
arthasTunnelMessageCommandCompleted = "jvm.backend.diagnostic.message.arthas_command_completed"
|
||||
arthasTunnelMessageCommandCanceled = "jvm.backend.diagnostic.message.arthas_command_canceled"
|
||||
|
||||
arthasTunnelErrorBaseURLRequired = "jvm.backend.diagnostic.arthas.base_url_required"
|
||||
arthasTunnelErrorBaseURLInvalid = "jvm.backend.diagnostic.arthas.base_url_invalid"
|
||||
arthasTunnelErrorTargetIDRequired = "jvm.backend.diagnostic.arthas.target_id_required"
|
||||
arthasTunnelErrorSchemeUnsupported = "jvm.backend.diagnostic.arthas.scheme_unsupported"
|
||||
arthasTunnelErrorSessionMissing = "jvm.backend.diagnostic.arthas.session_missing"
|
||||
arthasTunnelErrorSessionConfigChanged = "jvm.backend.diagnostic.arthas.session_config_changed"
|
||||
arthasTunnelErrorCommandAlreadyRunning = "jvm.backend.diagnostic.arthas.command_already_running"
|
||||
arthasTunnelErrorNoRunningCommand = "jvm.backend.diagnostic.arthas.no_running_command"
|
||||
arthasTunnelErrorCancelCommandMismatch = "jvm.backend.diagnostic.arthas.cancel_command_mismatch"
|
||||
arthasTunnelErrorConnectionNotReady = "jvm.backend.diagnostic.arthas.connection_not_ready"
|
||||
arthasTunnelErrorHTTPFailed = "jvm.backend.diagnostic.arthas.http_failed"
|
||||
arthasTunnelErrorConnectTimeout = "jvm.backend.diagnostic.arthas.connect_timeout"
|
||||
arthasTunnelErrorConnectCanceled = "jvm.backend.diagnostic.arthas.connect_canceled"
|
||||
arthasTunnelErrorConnectFailed = "jvm.backend.diagnostic.arthas.connect_failed"
|
||||
arthasTunnelErrorRequestEncodeFailed = "jvm.backend.diagnostic.arthas.request_encode_failed"
|
||||
arthasTunnelErrorWriteDeadlineFailed = "jvm.backend.diagnostic.arthas.write_deadline_failed"
|
||||
arthasTunnelErrorSendTimeout = "jvm.backend.diagnostic.arthas.send_timeout"
|
||||
arthasTunnelErrorSendCanceled = "jvm.backend.diagnostic.arthas.send_canceled"
|
||||
arthasTunnelErrorSendFailed = "jvm.backend.diagnostic.arthas.send_failed"
|
||||
arthasTunnelErrorReadDeadlineFailed = "jvm.backend.diagnostic.arthas.read_deadline_failed"
|
||||
arthasTunnelErrorReadTimeout = "jvm.backend.diagnostic.arthas.read_timeout"
|
||||
arthasTunnelErrorReadCanceled = "jvm.backend.diagnostic.arthas.read_canceled"
|
||||
arthasTunnelErrorReadFailed = "jvm.backend.diagnostic.arthas.read_failed"
|
||||
arthasTunnelErrorConnectionClosed = "jvm.backend.diagnostic.arthas.connection_closed"
|
||||
arthasTunnelErrorConnectionClosedCode = "jvm.backend.diagnostic.arthas.connection_closed_code"
|
||||
arthasTunnelErrorCommandTimeout = "jvm.backend.diagnostic.arthas.command_timeout"
|
||||
arthasTunnelErrorCommandCanceled = "jvm.backend.diagnostic.arthas.command_canceled"
|
||||
arthasTunnelErrorTerminalCommandEncodeFailed = "jvm.backend.diagnostic.arthas.terminal_command_encode_failed"
|
||||
)
|
||||
|
||||
var arthasPromptPattern = regexp.MustCompile(`\[arthas@[^\]]+\]\$ `)
|
||||
|
||||
type arthasTunnelTTYFrame struct {
|
||||
@@ -160,8 +193,8 @@ func (t *DiagnosticArthasTunnelTransport) ExecuteCommand(ctx context.Context, cf
|
||||
defer conn.Close()
|
||||
|
||||
if activeCommand.isCancelRequested() {
|
||||
t.emitChunk(req, "canceled", "Arthas 命令已取消")
|
||||
return fmt.Errorf("arthas tunnel command canceled")
|
||||
t.emitChunkWithContentKey(req, "canceled", arthasTunnelMessageCommandCanceled)
|
||||
return arthasTunnelCommandCanceledError()
|
||||
}
|
||||
|
||||
if err := activeCommand.send(arthasTunnelTTYFrame{
|
||||
@@ -222,11 +255,11 @@ func (t *DiagnosticArthasTunnelTransport) streamCommandUntilPrompt(
|
||||
}
|
||||
|
||||
if activeCommand.isCancelRequested() || strings.Contains(content, "^C") {
|
||||
t.emitChunk(req, "canceled", "Arthas 命令已取消")
|
||||
return fmt.Errorf("arthas tunnel command canceled")
|
||||
t.emitChunkWithContentKey(req, "canceled", arthasTunnelMessageCommandCanceled)
|
||||
return arthasTunnelCommandCanceledError()
|
||||
}
|
||||
|
||||
t.emitChunk(req, "completed", "Arthas 命令执行完成")
|
||||
t.emitChunkWithContentKey(req, "completed", arthasTunnelMessageCommandCompleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -242,10 +275,24 @@ func (t *DiagnosticArthasTunnelTransport) streamCommandUntilPrompt(
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DiagnosticArthasTunnelTransport) emitChunkWithContentKey(req DiagnosticCommandRequest, phase string, contentKey string) {
|
||||
t.emitChunkWithMetadata(req, phase, "", map[string]any{
|
||||
"contentKey": contentKey,
|
||||
})
|
||||
}
|
||||
|
||||
func (t *DiagnosticArthasTunnelTransport) emitChunk(req DiagnosticCommandRequest, phase string, content string) {
|
||||
t.emitChunkWithMetadata(req, phase, content, nil)
|
||||
}
|
||||
|
||||
func (t *DiagnosticArthasTunnelTransport) emitChunkWithMetadata(req DiagnosticCommandRequest, phase string, content string, metadata map[string]any) {
|
||||
if t.eventSink == nil {
|
||||
return
|
||||
}
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
metadata["transport"] = DiagnosticTransportArthasTunnel
|
||||
t.eventSink(DiagnosticEventChunk{
|
||||
SessionID: req.SessionID,
|
||||
CommandID: req.CommandID,
|
||||
@@ -253,26 +300,26 @@ func (t *DiagnosticArthasTunnelTransport) emitChunk(req DiagnosticCommandRequest
|
||||
Phase: phase,
|
||||
Content: content,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Metadata: map[string]any{
|
||||
"transport": DiagnosticTransportArthasTunnel,
|
||||
},
|
||||
Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func newArthasTunnelRuntime(cfg connection.ConnectionConfig) (arthasTunnelRuntime, error) {
|
||||
baseURLText := strings.TrimSpace(cfg.JVM.Diagnostic.BaseURL)
|
||||
if baseURLText == "" {
|
||||
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 地址不能为空")
|
||||
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorBaseURLRequired, nil, nil)
|
||||
}
|
||||
|
||||
baseURL, err := url.Parse(baseURLText)
|
||||
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 地址格式不正确:%s", baseURLText)
|
||||
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorBaseURLInvalid, map[string]any{
|
||||
"detail": baseURLText,
|
||||
}, err)
|
||||
}
|
||||
|
||||
targetID := strings.TrimSpace(cfg.JVM.Diagnostic.TargetID)
|
||||
if targetID == "" {
|
||||
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 需要填写目标实例标识(targetId / agentId)")
|
||||
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorTargetIDRequired, nil, nil)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(baseURL.Scheme))
|
||||
@@ -283,7 +330,9 @@ func newArthasTunnelRuntime(cfg connection.ConnectionConfig) (arthasTunnelRuntim
|
||||
baseURL.Scheme = "wss"
|
||||
case "ws", "wss":
|
||||
default:
|
||||
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 仅支持 http/https/ws/wss 地址:%s", baseURL.Scheme)
|
||||
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorSchemeUnsupported, map[string]any{
|
||||
"scheme": baseURL.Scheme,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
baseURL.Path = resolveArthasTunnelWSPath(baseURL.Path)
|
||||
@@ -333,9 +382,11 @@ func (r arthasTunnelRuntime) dial(ctx context.Context) (*websocket.Conn, error)
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
return nil, fmt.Errorf("Arthas Tunnel 连接失败:HTTP %s", resp.Status)
|
||||
return nil, arthasTunnelLocalizedError(arthasTunnelErrorHTTPFailed, map[string]any{
|
||||
"status": resp.Status,
|
||||
}, err)
|
||||
}
|
||||
return nil, translateArthasTunnelIOError("建立 Arthas Tunnel WebSocket 连接", err, r.timeout)
|
||||
return nil, translateArthasTunnelIOError("connect", err, r.timeout)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -362,14 +413,18 @@ func (r arthasTunnelRuntime) waitForPrompt(ctx context.Context, conn *websocket.
|
||||
func (r arthasTunnelRuntime) writeFrame(conn *websocket.Conn, frame arthasTunnelTTYFrame) error {
|
||||
payload, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Arthas Tunnel 请求编码失败:%w", err)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorRequestEncodeFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(r.timeout)); err != nil {
|
||||
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorWriteDeadlineFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return translateArthasTunnelIOError("向 Arthas Tunnel 发送终端指令", err, r.timeout)
|
||||
return translateArthasTunnelIOError("send", err, r.timeout)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -382,7 +437,9 @@ func (r arthasTunnelRuntime) readTextFrame(ctx context.Context, conn *websocket.
|
||||
}
|
||||
|
||||
if err := conn.SetReadDeadline(readDeadline); err != nil {
|
||||
return "", fmt.Errorf("Arthas Tunnel 读取超时设置失败:%w", err)
|
||||
return "", arthasTunnelLocalizedError(arthasTunnelErrorReadDeadlineFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
|
||||
messageType, payload, err := conn.ReadMessage()
|
||||
@@ -404,59 +461,79 @@ func (r arthasTunnelRuntime) readTextFrame(ctx context.Context, conn *websocket.
|
||||
}
|
||||
|
||||
func translateArthasTunnelIOError(action string, err error, timeout time.Duration) error {
|
||||
timeoutKey, canceledKey, failedKey := arthasTunnelIOErrorKeys(action)
|
||||
if errors.Is(err, context.DeadlineExceeded) || isArthasTunnelTimeout(err) {
|
||||
return fmt.Errorf("%s超时,%s 内未收到响应", action, timeout)
|
||||
return arthasTunnelLocalizedError(timeoutKey, map[string]any{
|
||||
"timeout": timeout.String(),
|
||||
}, err)
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return fmt.Errorf("%s已取消", action)
|
||||
return arthasTunnelLocalizedError(canceledKey, nil, err)
|
||||
}
|
||||
return fmt.Errorf("%s失败:%w", action, err)
|
||||
return arthasTunnelLocalizedError(failedKey, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
|
||||
func translateArthasTunnelReadError(err error, timeout time.Duration) error {
|
||||
var closeErr *websocket.CloseError
|
||||
if errors.As(err, &closeErr) {
|
||||
if strings.TrimSpace(closeErr.Text) != "" {
|
||||
return fmt.Errorf("Arthas Tunnel 连接已关闭:%s", translateArthasTunnelCloseReason(closeErr.Text))
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionClosed, map[string]any{
|
||||
"detail": strings.TrimSpace(closeErr.Text),
|
||||
}, err)
|
||||
}
|
||||
return fmt.Errorf("Arthas Tunnel 连接已关闭:code=%d", closeErr.Code)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionClosedCode, map[string]any{
|
||||
"code": closeErr.Code,
|
||||
}, err)
|
||||
}
|
||||
return translateArthasTunnelIOError("读取 Arthas Tunnel 输出", err, timeout)
|
||||
return translateArthasTunnelIOError("read", err, timeout)
|
||||
}
|
||||
|
||||
func translateArthasTunnelContextError(err error, timeout time.Duration) error {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("Arthas Tunnel 命令执行超时,%s 内未完成", timeout)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorCommandTimeout, map[string]any{
|
||||
"timeout": timeout.String(),
|
||||
}, err)
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return errors.New("Arthas Tunnel 命令已取消")
|
||||
return arthasTunnelCommandCanceledError()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func arthasTunnelIOErrorKeys(action string) (timeoutKey string, canceledKey string, failedKey string) {
|
||||
switch action {
|
||||
case "connect":
|
||||
return arthasTunnelErrorConnectTimeout, arthasTunnelErrorConnectCanceled, arthasTunnelErrorConnectFailed
|
||||
case "send":
|
||||
return arthasTunnelErrorSendTimeout, arthasTunnelErrorSendCanceled, arthasTunnelErrorSendFailed
|
||||
default:
|
||||
return arthasTunnelErrorReadTimeout, arthasTunnelErrorReadCanceled, arthasTunnelErrorReadFailed
|
||||
}
|
||||
}
|
||||
|
||||
func arthasTunnelCommandCanceledError() error {
|
||||
return arthasTunnelLocalizedError(
|
||||
arthasTunnelErrorCommandCanceled,
|
||||
nil,
|
||||
errors.New("arthas tunnel command canceled"),
|
||||
)
|
||||
}
|
||||
|
||||
func arthasTunnelLocalizedError(key string, params map[string]any, cause error) error {
|
||||
return &LocalizedError{
|
||||
Key: key,
|
||||
Params: params,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
func isArthasTunnelTimeout(err error) bool {
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
func translateArthasTunnelCloseReason(reason string) string {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
lowerReason := strings.ToLower(trimmed)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lowerReason, "can not find arthas agent by id"):
|
||||
parts := strings.Split(trimmed, ":")
|
||||
if len(parts) > 1 {
|
||||
return "找不到目标实例 " + strings.TrimSpace(parts[len(parts)-1]) + ",请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
|
||||
}
|
||||
return "找不到目标实例,请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
|
||||
case strings.Contains(lowerReason, "arthas agent id can not be null"):
|
||||
return "缺少目标实例标识,请填写 targetId / agentId"
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func newArthasTunnelSessionRegistry() *arthasTunnelSessionRegistry {
|
||||
return &arthasTunnelSessionRegistry{
|
||||
sessions: make(map[string]arthasTunnelSessionMeta),
|
||||
@@ -491,13 +568,13 @@ func (r *arthasTunnelSessionRegistry) beginCommand(sessionID string, commandID s
|
||||
r.pruneLocked(time.Now().UnixMilli())
|
||||
meta, ok := r.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, errors.New("诊断会话不存在,请重新创建 Arthas Tunnel 会话")
|
||||
return nil, arthasTunnelLocalizedError(arthasTunnelErrorSessionMissing, nil, nil)
|
||||
}
|
||||
if !meta.matchesConfig(cfg) {
|
||||
return nil, errors.New("Arthas Tunnel 会话配置已变化,请重新创建诊断会话")
|
||||
return nil, arthasTunnelLocalizedError(arthasTunnelErrorSessionConfigChanged, nil, nil)
|
||||
}
|
||||
if existing := r.active[sessionID]; existing != nil {
|
||||
return nil, errors.New("当前 Arthas Tunnel 会话已有命令在执行,请先等待完成或取消")
|
||||
return nil, arthasTunnelLocalizedError(arthasTunnelErrorCommandAlreadyRunning, nil, nil)
|
||||
}
|
||||
|
||||
activeCommand := &arthasTunnelActiveCommand{commandID: commandID}
|
||||
@@ -567,10 +644,10 @@ func (r *arthasTunnelSessionRegistry) cancelCommand(sessionID string, commandID
|
||||
r.mu.Unlock()
|
||||
|
||||
if activeCommand == nil {
|
||||
return errors.New("当前 Arthas Tunnel 会话没有正在执行的命令")
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorNoRunningCommand, nil, nil)
|
||||
}
|
||||
if activeCommand.commandID != commandID {
|
||||
return errors.New("当前 Arthas Tunnel 会话的活动命令与待取消命令不一致")
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorCancelCommandMismatch, nil, nil)
|
||||
}
|
||||
return activeCommand.requestCancel()
|
||||
}
|
||||
@@ -598,22 +675,28 @@ func (c *arthasTunnelActiveCommand) send(frame arthasTunnelTTYFrame) error {
|
||||
conn := c.conn
|
||||
c.mu.RUnlock()
|
||||
if conn == nil {
|
||||
return errors.New("Arthas Tunnel 连接尚未建立完成,请稍后重试")
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionNotReady, nil, nil)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Arthas Tunnel 终端指令编码失败:%w", err)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorTerminalCommandEncodeFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorWriteDeadlineFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return fmt.Errorf("向 Arthas Tunnel 发送终端指令失败:%w", err)
|
||||
return arthasTunnelLocalizedError(arthasTunnelErrorSendFailed, map[string]any{
|
||||
"detail": err.Error(),
|
||||
}, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package jvm
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -138,6 +139,26 @@ func testArthasTunnelConfig(baseURL string) connection.ConnectionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func assertArthasTunnelLocalizedError(t *testing.T, err error, key string, params map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected localized error %q, got nil", key)
|
||||
}
|
||||
var localized *LocalizedError
|
||||
if !errors.As(err, &localized) || localized == nil {
|
||||
t.Fatalf("expected LocalizedError %q, got %T: %v", key, err, err)
|
||||
}
|
||||
if localized.Key != key {
|
||||
t.Fatalf("expected localized key %q, got %q with params %#v", key, localized.Key, localized.Params)
|
||||
}
|
||||
for name, expected := range params {
|
||||
if localized.Params[name] != expected {
|
||||
t.Fatalf("expected param %s=%#v, got %#v in %#v", name, expected, localized.Params[name], localized.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticArthasTunnelExecuteCommandStreamsUntilPrompt(t *testing.T) {
|
||||
commandSeen := make(chan struct{}, 1)
|
||||
server := newFakeArthasTunnelServer(t, func(conn *websocket.Conn, frame fakeArthasTTYFrame) {
|
||||
@@ -234,6 +255,12 @@ func TestDiagnosticArthasTunnelExecuteCommandStreamsUntilPrompt(t *testing.T) {
|
||||
if chunks[len(chunks)-1].Phase != "completed" {
|
||||
t.Fatalf("expected terminal chunk completed, got %#v", chunks[len(chunks)-1])
|
||||
}
|
||||
if chunks[len(chunks)-1].Content != "" {
|
||||
t.Fatalf("expected completed chunk content to be localized at app boundary, got %#v", chunks[len(chunks)-1])
|
||||
}
|
||||
if chunks[len(chunks)-1].Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_completed" {
|
||||
t.Fatalf("expected completed contentKey metadata, got %#v", chunks[len(chunks)-1].Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T) {
|
||||
@@ -305,9 +332,7 @@ func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "canceled") {
|
||||
t.Fatalf("expected canceled error, got %v", err)
|
||||
}
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_canceled", nil)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected ExecuteCommand to exit after cancellation")
|
||||
}
|
||||
@@ -315,6 +340,16 @@ func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T
|
||||
if len(chunks) == 0 {
|
||||
t.Fatal("expected cancel flow to emit chunks")
|
||||
}
|
||||
lastChunk := chunks[len(chunks)-1]
|
||||
if lastChunk.Phase != "canceled" {
|
||||
t.Fatalf("expected final chunk phase canceled, got %#v", lastChunk)
|
||||
}
|
||||
if lastChunk.Content != "" {
|
||||
t.Fatalf("expected canceled chunk content to be localized at app boundary, got %#v", lastChunk)
|
||||
}
|
||||
if lastChunk.Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_canceled" {
|
||||
t.Fatalf("expected canceled contentKey metadata, got %#v", lastChunk.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArthasTunnelActiveCommandAcceptsCancelBeforeConnectionAttach(t *testing.T) {
|
||||
@@ -358,9 +393,7 @@ func TestDiagnosticArthasTunnelRejectsSessionConfigDrift(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected config drift to reject stale Arthas Tunnel session")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "会话配置已变化") {
|
||||
t.Fatalf("expected config drift error, got %v", err)
|
||||
}
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_config_changed", nil)
|
||||
}
|
||||
|
||||
func TestArthasTunnelSessionRegistryPrunesExpiredSessions(t *testing.T) {
|
||||
@@ -402,9 +435,7 @@ func TestDiagnosticArthasTunnelRequiresTargetID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected missing targetId to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "target") {
|
||||
t.Fatalf("expected targetId error, got %v", err)
|
||||
}
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.target_id_required", nil)
|
||||
}
|
||||
|
||||
func TestDiagnosticArthasTunnelProbeCapabilitiesAndCloseSession(t *testing.T) {
|
||||
@@ -442,7 +473,93 @@ func TestDiagnosticArthasTunnelProbeCapabilitiesAndCloseSession(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected closed synthetic session to reject command execution")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "诊断会话不存在") {
|
||||
t.Fatalf("expected closed-session error, got %v", err)
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_missing", nil)
|
||||
}
|
||||
|
||||
func TestDiagnosticArthasTunnelRuntimeReturnsLocalizedValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg connection.ConnectionConfig
|
||||
key string
|
||||
params map[string]any
|
||||
}{
|
||||
{
|
||||
name: "base URL required",
|
||||
cfg: func() connection.ConnectionConfig {
|
||||
cfg := testArthasTunnelConfig(" ")
|
||||
return cfg
|
||||
}(),
|
||||
key: "jvm.backend.diagnostic.arthas.base_url_required",
|
||||
},
|
||||
{
|
||||
name: "base URL invalid keeps raw detail",
|
||||
cfg: func() connection.ConnectionConfig {
|
||||
cfg := testArthasTunnelConfig("://bad-url")
|
||||
return cfg
|
||||
}(),
|
||||
key: "jvm.backend.diagnostic.arthas.base_url_invalid",
|
||||
params: map[string]any{"detail": "://bad-url"},
|
||||
},
|
||||
{
|
||||
name: "target ID required",
|
||||
cfg: func() connection.ConnectionConfig {
|
||||
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
|
||||
cfg.JVM.Diagnostic.TargetID = " "
|
||||
return cfg
|
||||
}(),
|
||||
key: "jvm.backend.diagnostic.arthas.target_id_required",
|
||||
},
|
||||
{
|
||||
name: "scheme unsupported keeps raw scheme",
|
||||
cfg: func() connection.ConnectionConfig {
|
||||
cfg := testArthasTunnelConfig("ftp://127.0.0.1:7777")
|
||||
return cfg
|
||||
}(),
|
||||
key: "jvm.backend.diagnostic.arthas.scheme_unsupported",
|
||||
params: map[string]any{"scheme": "ftp"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := newArthasTunnelRuntime(tt.cfg)
|
||||
assertArthasTunnelLocalizedError(t, err, tt.key, tt.params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArthasTunnelSessionRegistryReturnsLocalizedStateErrors(t *testing.T) {
|
||||
registry := newArthasTunnelSessionRegistry()
|
||||
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
|
||||
handle := registry.createSession(cfg)
|
||||
|
||||
_, err := registry.beginCommand("missing-session", "cmd-missing", cfg)
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_missing", nil)
|
||||
|
||||
if _, err := registry.beginCommand(handle.SessionID, "cmd-active", cfg); err != nil {
|
||||
t.Fatalf("beginCommand returned error: %v", err)
|
||||
}
|
||||
_, err = registry.beginCommand(handle.SessionID, "cmd-second", cfg)
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_already_running", nil)
|
||||
|
||||
err = registry.cancelCommand(handle.SessionID, "cmd-other")
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.cancel_command_mismatch", nil)
|
||||
registry.finishCommand(handle.SessionID, "cmd-active")
|
||||
|
||||
err = registry.cancelCommand(handle.SessionID, "cmd-active")
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.no_running_command", nil)
|
||||
}
|
||||
|
||||
func TestArthasTunnelActiveCommandReturnsLocalizedConnectionNotReady(t *testing.T) {
|
||||
activeCommand := &arthasTunnelActiveCommand{commandID: "cmd-not-ready"}
|
||||
|
||||
err := activeCommand.send(arthasTunnelTTYFrame{Action: "read", Data: "thread -n 1"})
|
||||
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.connection_not_ready", nil)
|
||||
}
|
||||
|
||||
func TestTranslateArthasTunnelContextErrorReturnsLocalizedCommandCanceled(t *testing.T) {
|
||||
err := translateArthasTunnelContextError(context.Canceled, time.Second)
|
||||
|
||||
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_canceled", nil)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ import (
|
||||
|
||||
const defaultDiagnosticTimeoutSeconds = 15
|
||||
|
||||
const (
|
||||
diagnosticErrorTransportUnsupportedKey = "jvm.backend.diagnostic.error.transport_unsupported"
|
||||
diagnosticErrorDisabledKey = "jvm.backend.diagnostic.error.disabled"
|
||||
diagnosticErrorCommandRequiredKey = "jvm.backend.diagnostic.error.command_required"
|
||||
diagnosticPolicyMultiCommandUnsupportedKey = "jvm.backend.diagnostic.policy.multiline_not_supported"
|
||||
diagnosticPolicyObserveCommandNotAllowedKey = "jvm.backend.diagnostic.policy.observe_not_allowed"
|
||||
diagnosticPolicyTraceCommandNotAllowedKey = "jvm.backend.diagnostic.policy.trace_not_allowed"
|
||||
diagnosticPolicyMutatingNotAllowedKey = "jvm.backend.diagnostic.policy.mutating_not_allowed"
|
||||
diagnosticPolicyReadOnlyObserveOnlyKey = "jvm.backend.diagnostic.policy.read_only_observe_only"
|
||||
)
|
||||
|
||||
var observeDiagnosticCommands = map[string]struct{}{
|
||||
"dashboard": {},
|
||||
"thread": {},
|
||||
@@ -36,7 +47,12 @@ func NormalizeDiagnosticConfig(cfg connection.ConnectionConfig) (connection.JVMD
|
||||
normalized := cfg.JVM.Diagnostic
|
||||
normalized.Transport = normalizeDiagnosticTransport(normalized.Transport)
|
||||
if normalized.Transport == "" {
|
||||
return connection.JVMDiagnosticConfig{}, fmt.Errorf("不支持的 JVM 诊断传输模式:%s", strings.TrimSpace(cfg.JVM.Diagnostic.Transport))
|
||||
return connection.JVMDiagnosticConfig{}, &LocalizedError{
|
||||
Key: diagnosticErrorTransportUnsupportedKey,
|
||||
Params: map[string]any{
|
||||
"transport": cfg.JVM.Diagnostic.Transport,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
normalized.BaseURL = strings.TrimSpace(normalized.BaseURL)
|
||||
@@ -54,7 +70,7 @@ func NormalizeDiagnosticConfig(cfg connection.ConnectionConfig) (connection.JVMD
|
||||
|
||||
func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command string) (string, error) {
|
||||
if !cfg.Enabled {
|
||||
return "", fmt.Errorf("当前连接未启用 JVM 诊断增强模式")
|
||||
return "", &LocalizedError{Key: diagnosticErrorDisabledKey}
|
||||
}
|
||||
|
||||
category, normalizedCommand, err := classifyDiagnosticCommand(command)
|
||||
@@ -65,15 +81,24 @@ func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command
|
||||
switch category {
|
||||
case DiagnosticCommandCategoryObserve:
|
||||
if !cfg.AllowObserveCommands {
|
||||
return "", fmt.Errorf("当前连接未开放观察类诊断命令:%s", normalizedCommand)
|
||||
return "", &LocalizedError{
|
||||
Key: diagnosticPolicyObserveCommandNotAllowedKey,
|
||||
Params: map[string]any{"command": normalizedCommand},
|
||||
}
|
||||
}
|
||||
case DiagnosticCommandCategoryTrace:
|
||||
if !cfg.AllowTraceCommands {
|
||||
return "", fmt.Errorf("当前连接未开放跟踪类诊断命令:%s", normalizedCommand)
|
||||
return "", &LocalizedError{
|
||||
Key: diagnosticPolicyTraceCommandNotAllowedKey,
|
||||
Params: map[string]any{"command": normalizedCommand},
|
||||
}
|
||||
}
|
||||
default:
|
||||
if !cfg.AllowMutatingCommands {
|
||||
return "", fmt.Errorf("当前连接未开放高风险诊断命令:%s", normalizedCommand)
|
||||
return "", &LocalizedError{
|
||||
Key: diagnosticPolicyMutatingNotAllowedKey,
|
||||
Params: map[string]any{"command": normalizedCommand},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +119,7 @@ func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command
|
||||
if cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly {
|
||||
switch category {
|
||||
case DiagnosticCommandCategoryTrace, DiagnosticCommandCategoryMutating:
|
||||
return "", fmt.Errorf("当前连接为只读模式,仅允许观察类诊断命令")
|
||||
return "", &LocalizedError{Key: diagnosticPolicyReadOnlyObserveOnlyKey}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +129,10 @@ func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command
|
||||
func classifyDiagnosticCommand(command string) (string, string, error) {
|
||||
normalizedCommand := strings.TrimSpace(command)
|
||||
if normalizedCommand == "" {
|
||||
return "", "", fmt.Errorf("诊断命令不能为空")
|
||||
return "", "", &LocalizedError{Key: diagnosticErrorCommandRequiredKey}
|
||||
}
|
||||
if strings.ContainsAny(normalizedCommand, "\r\n") {
|
||||
return "", "", fmt.Errorf("诊断命令不支持换行或多命令输入")
|
||||
return "", "", &LocalizedError{Key: diagnosticPolicyMultiCommandUnsupportedKey}
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.ToLower(normalizedCommand))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package jvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -80,3 +83,148 @@ func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.
|
||||
t.Fatalf("expected mutating command to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticConfigPolicyErrorsReturnLocalizedKeys(t *testing.T) {
|
||||
enabledAll := connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
AllowObserveCommands: true,
|
||||
AllowTraceCommands: true,
|
||||
AllowMutatingCommands: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
wantKey string
|
||||
wantParams map[string]any
|
||||
}{
|
||||
{
|
||||
name: "unsupported transport keeps raw transport parameter",
|
||||
run: func() error {
|
||||
_, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
JVM: connection.JVMConfig{
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Transport: " websocket ",
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.error.transport_unsupported",
|
||||
wantParams: map[string]any{"transport": " websocket "},
|
||||
},
|
||||
{
|
||||
name: "disabled diagnostic mode",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{}, "thread")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.error.disabled",
|
||||
},
|
||||
{
|
||||
name: "empty command",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(enabledAll, " ")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.error.command_required",
|
||||
},
|
||||
{
|
||||
name: "multiline command",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(enabledAll, "thread\nwatch demo.Service call '{params}'")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.policy.multiline_not_supported",
|
||||
},
|
||||
{
|
||||
name: "observe command not allowed keeps normalized command parameter",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
}, " thread -n 1 ")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.policy.observe_not_allowed",
|
||||
wantParams: map[string]any{"command": "thread -n 1"},
|
||||
},
|
||||
{
|
||||
name: "trace command not allowed keeps normalized command parameter",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
AllowObserveCommands: true,
|
||||
}, "watch demo.Service call '{params}'")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.policy.trace_not_allowed",
|
||||
wantParams: map[string]any{"command": "watch demo.Service call '{params}'"},
|
||||
},
|
||||
{
|
||||
name: "mutating command not allowed keeps normalized command parameter",
|
||||
run: func() error {
|
||||
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
AllowObserveCommands: true,
|
||||
}, "ognl '@java.lang.System@exit(0)'")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.policy.mutating_not_allowed",
|
||||
wantParams: map[string]any{"command": "ognl '@java.lang.System@exit(0)'"},
|
||||
},
|
||||
{
|
||||
name: "read only rejects non observe command",
|
||||
run: func() error {
|
||||
readOnly := true
|
||||
_, err := ValidateDiagnosticExecutionPolicy(connection.ConnectionConfig{
|
||||
Type: "jvm",
|
||||
JVM: connection.JVMConfig{
|
||||
ReadOnly: &readOnly,
|
||||
Diagnostic: connection.JVMDiagnosticConfig{
|
||||
Enabled: true,
|
||||
Transport: DiagnosticTransportAgentBridge,
|
||||
AllowObserveCommands: true,
|
||||
AllowTraceCommands: true,
|
||||
},
|
||||
},
|
||||
}, "trace demo.Service call")
|
||||
return err
|
||||
},
|
||||
wantKey: "jvm.backend.diagnostic.policy.read_only_observe_only",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected localized error")
|
||||
}
|
||||
|
||||
var localized *LocalizedError
|
||||
if !errors.As(err, &localized) {
|
||||
t.Fatalf("expected LocalizedError, got %T: %v", err, err)
|
||||
}
|
||||
if localized.Key != tt.wantKey {
|
||||
t.Fatalf("localized key=%q, want %q", localized.Key, tt.wantKey)
|
||||
}
|
||||
if !reflect.DeepEqual(localized.Params, tt.wantParams) {
|
||||
t.Fatalf("localized params=%#v, want %#v", localized.Params, tt.wantParams)
|
||||
}
|
||||
if containsCJK(err.Error()) {
|
||||
t.Fatalf("error still exposes legacy Chinese text: %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsCJK(value string) bool {
|
||||
return strings.ContainsFunc(value, func(r rune) bool {
|
||||
return r >= '\u4e00' && r <= '\u9fff'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,38 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
const (
|
||||
changeBlockedReadOnlyKey = "jvm.backend.error.change_blocked_read_only"
|
||||
previewConfirmationMissingKey = "jvm.backend.error.preview_confirmation_missing"
|
||||
confirmationTokenMissingKey = "jvm.backend.error.confirmation_token_missing"
|
||||
confirmationTokenInvalidKey = "jvm.backend.error.confirmation_token_invalid"
|
||||
changeConfirmationTokenFailedKey = "jvm.backend.error.change_confirmation_token_failed"
|
||||
)
|
||||
|
||||
// LocalizedError marks JVM package errors that the app boundary can translate.
|
||||
type LocalizedError struct {
|
||||
Key string
|
||||
Params map[string]any
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *LocalizedError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Cause != nil {
|
||||
return e.Cause.Error()
|
||||
}
|
||||
return e.Key
|
||||
}
|
||||
|
||||
func (e *LocalizedError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// BuildChangePreview builds a guarded preview for JVM mutations.
|
||||
// It always produces a local before/after baseline and, when writes are still
|
||||
// allowed, merges provider preview details on top of that baseline.
|
||||
@@ -70,7 +102,8 @@ func BuildChangePreview(
|
||||
if normalized.JVM.ReadOnly != nil && *normalized.JVM.ReadOnly {
|
||||
preview.Allowed = false
|
||||
preview.RiskLevel = "high"
|
||||
preview.BlockingReason = "当前连接为只读,禁止写入"
|
||||
preview.BlockingReason = changeBlockedReadOnlyKey
|
||||
preview.blockingReasonKey = changeBlockedReadOnlyKey
|
||||
}
|
||||
if normalized.JVM.Environment == EnvPROD {
|
||||
preview.RequiresConfirmation = true
|
||||
@@ -200,13 +233,13 @@ func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error
|
||||
previewToken := strings.TrimSpace(preview.ConfirmationToken)
|
||||
requestToken := strings.TrimSpace(req.ConfirmationToken)
|
||||
if previewToken == "" {
|
||||
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
|
||||
return &LocalizedError{Key: previewConfirmationMissingKey}
|
||||
}
|
||||
if requestToken == "" {
|
||||
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
|
||||
return &LocalizedError{Key: confirmationTokenMissingKey}
|
||||
}
|
||||
if previewToken != requestToken {
|
||||
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
|
||||
return &LocalizedError{Key: confirmationTokenInvalidKey}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -244,7 +277,13 @@ func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeReq
|
||||
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err)
|
||||
return "", &LocalizedError{
|
||||
Key: changeConfirmationTokenFailedKey,
|
||||
Params: map[string]any{
|
||||
"detail": err.Error(),
|
||||
},
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(encoded)
|
||||
|
||||
@@ -65,8 +65,8 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) {
|
||||
if preview.Allowed {
|
||||
t.Fatalf("expected preview to be blocked, got %#v", preview)
|
||||
}
|
||||
if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") {
|
||||
t.Fatalf("expected readonly blocking reason, got %#v", preview)
|
||||
if preview.BlockingReason != changeBlockedReadOnlyKey {
|
||||
t.Fatalf("expected readonly blocking reason key %q, got %#v", changeBlockedReadOnlyKey, preview)
|
||||
}
|
||||
if strings.TrimSpace(preview.ConfirmationToken) != "" {
|
||||
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
|
||||
@@ -461,7 +461,7 @@ func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
|
||||
func TestBuildChangePreviewReturnsLocalizedConfirmationTokenError(t *testing.T) {
|
||||
readOnly := false
|
||||
_, err := BuildChangePreview(context.Background(), fakeGuardProvider{
|
||||
preview: ChangePreview{
|
||||
@@ -492,8 +492,18 @@ func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "确认令牌") {
|
||||
t.Fatalf("expected error to mention confirmation token, got %v", err)
|
||||
var localized *LocalizedError
|
||||
if !errors.As(err, &localized) {
|
||||
t.Fatalf("expected localized confirmation token error, got %T %v", err, err)
|
||||
}
|
||||
if localized.Key != changeConfirmationTokenFailedKey {
|
||||
t.Fatalf("expected key %q, got %q", changeConfirmationTokenFailedKey, localized.Key)
|
||||
}
|
||||
if localized.Params["detail"] != "json: unsupported type: func()" {
|
||||
t.Fatalf("expected raw marshal detail, got %#v", localized.Params)
|
||||
}
|
||||
if err.Error() != "json: unsupported type: func()" {
|
||||
t.Fatalf("expected raw fallback error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,13 +513,21 @@ func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T)
|
||||
RequiresConfirmation: true,
|
||||
ConfirmationToken: "token-a",
|
||||
}
|
||||
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil {
|
||||
t.Fatal("expected missing confirmation token to be rejected")
|
||||
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); localizedErrorKey(err) != confirmationTokenMissingKey {
|
||||
t.Fatalf("expected missing confirmation token key %q, got %T %v", confirmationTokenMissingKey, err, err)
|
||||
}
|
||||
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil {
|
||||
t.Fatal("expected mismatched confirmation token to be rejected")
|
||||
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); localizedErrorKey(err) != confirmationTokenInvalidKey {
|
||||
t.Fatalf("expected mismatched confirmation token key %q, got %T %v", confirmationTokenInvalidKey, err, err)
|
||||
}
|
||||
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil {
|
||||
t.Fatalf("expected matching confirmation token to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func localizedErrorKey(err error) string {
|
||||
var localized *LocalizedError
|
||||
if errors.As(err, &localized) && localized != nil {
|
||||
return localized.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -53,7 +53,13 @@ func (p *HTTPProvider) ProbeCapabilities(_ context.Context, cfg connection.Conne
|
||||
DisplayLabel: "Endpoint",
|
||||
Reason: func() string {
|
||||
if readOnly {
|
||||
return "当前连接只读"
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
reasonKey: func() string {
|
||||
if readOnly {
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
|
||||
@@ -233,24 +233,49 @@ func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProviderProbeCapabilitiesReflectsReadOnlyConnection(t *testing.T) {
|
||||
provider := NewHTTPProvider()
|
||||
cfg := newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3)
|
||||
func TestProvidersProbeCapabilitiesUseReadOnlyReasonKey(t *testing.T) {
|
||||
readOnly := true
|
||||
cfg.JVM.ReadOnly = &readOnly
|
||||
cases := []struct {
|
||||
name string
|
||||
provider Provider
|
||||
cfg connection.ConnectionConfig
|
||||
}{
|
||||
{
|
||||
name: "jmx",
|
||||
provider: NewJMXProvider(),
|
||||
cfg: newJMXProviderTestConfig(),
|
||||
},
|
||||
{
|
||||
name: "endpoint",
|
||||
provider: NewHTTPProvider(),
|
||||
cfg: newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3),
|
||||
},
|
||||
{
|
||||
name: "agent",
|
||||
provider: NewAgentProvider(),
|
||||
cfg: newAgentProviderTestConfig("https://orders.internal/agent", 3),
|
||||
},
|
||||
}
|
||||
|
||||
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeCapabilities returned error: %v", err)
|
||||
}
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected one capability, got %#v", caps)
|
||||
}
|
||||
if caps[0].CanWrite {
|
||||
t.Fatalf("expected endpoint capability to be readonly, got %#v", caps[0])
|
||||
}
|
||||
if caps[0].Reason != "当前连接只读" {
|
||||
t.Fatalf("expected readonly reason, got %#v", caps[0])
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := tc.cfg
|
||||
cfg.JVM.ReadOnly = &readOnly
|
||||
|
||||
caps, err := tc.provider.ProbeCapabilities(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeCapabilities returned error: %v", err)
|
||||
}
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("expected one capability, got %#v", caps)
|
||||
}
|
||||
if caps[0].CanWrite {
|
||||
t.Fatalf("expected capability to be readonly, got %#v", caps[0])
|
||||
}
|
||||
if caps[0].Reason != changeBlockedReadOnlyKey {
|
||||
t.Fatalf("expected readonly reason key %q, got %#v", changeBlockedReadOnlyKey, caps[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.Conn
|
||||
DisplayLabel: "JMX",
|
||||
Reason: func() string {
|
||||
if readOnly {
|
||||
return "当前连接只读"
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
reasonKey: func() string {
|
||||
if readOnly {
|
||||
return changeBlockedReadOnlyKey
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
|
||||
@@ -3,6 +3,7 @@ package jvm
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,6 +18,16 @@ const (
|
||||
maxMonitoringSampleFailures = 3
|
||||
)
|
||||
|
||||
const (
|
||||
monitoringSnapshotUnsupportedKey = "jvm.backend.monitoring.error.snapshot_unsupported"
|
||||
monitoringSessionNotFoundKey = "jvm.backend.monitoring.error.session_not_found"
|
||||
)
|
||||
|
||||
const (
|
||||
monitoringWarningMarkerPrefix = "__gonavi_i18n__:"
|
||||
monitoringSampleAutoStoppedWarningKey = "jvm.backend.monitoring.warning.sample_auto_stopped"
|
||||
)
|
||||
|
||||
var monitoringProviderFactory = NewProvider
|
||||
|
||||
type monitoringManager struct {
|
||||
@@ -94,7 +105,12 @@ func (m *monitoringManager) Start(ctx context.Context, raw connection.Connection
|
||||
|
||||
monitoringProvider, ok := provider.(MonitoringCapableProvider)
|
||||
if !ok {
|
||||
return MonitoringSessionSnapshot{}, fmt.Errorf("%s provider does not implement monitoring snapshot yet", ModeDisplayLabel(providerMode))
|
||||
return MonitoringSessionSnapshot{}, &LocalizedError{
|
||||
Key: monitoringSnapshotUnsupportedKey,
|
||||
Params: map[string]any{
|
||||
"provider": ModeDisplayLabel(providerMode),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
generation := session.reset(connectionID, providerMode)
|
||||
@@ -118,7 +134,7 @@ func (m *monitoringManager) Stop(connectionID string, providerMode string) error
|
||||
session, ok := m.sessions[m.sessionKey(connectionID, providerMode)]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode)
|
||||
return monitoringSessionNotFoundError(connectionID, providerMode)
|
||||
}
|
||||
|
||||
session.stop()
|
||||
@@ -130,11 +146,21 @@ func (m *monitoringManager) GetHistory(connectionID string, providerMode string)
|
||||
session, ok := m.sessions[m.sessionKey(connectionID, providerMode)]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return MonitoringSessionSnapshot{}, fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode)
|
||||
return MonitoringSessionSnapshot{}, monitoringSessionNotFoundError(connectionID, providerMode)
|
||||
}
|
||||
return session.snapshot(), nil
|
||||
}
|
||||
|
||||
func monitoringSessionNotFoundError(connectionID string, providerMode string) error {
|
||||
return &LocalizedError{
|
||||
Key: monitoringSessionNotFoundKey,
|
||||
Params: map[string]any{
|
||||
"connectionId": connectionID,
|
||||
"providerMode": providerMode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *monitoringSession) appendPoint(point JVMMonitoringPoint) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -164,7 +190,7 @@ func (m *monitoringManager) runSampler(ctx context.Context, provider MonitoringC
|
||||
consecutiveFailures++
|
||||
session.appendWarning(err.Error())
|
||||
if consecutiveFailures >= maxMonitoringSampleFailures {
|
||||
session.appendWarning(fmt.Sprintf("监控采样连续失败 %d 次,已自动停止本次监控会话", consecutiveFailures))
|
||||
session.appendWarning(FormatMonitoringSampleAutoStoppedWarning(consecutiveFailures))
|
||||
session.markStopped(generation)
|
||||
return
|
||||
}
|
||||
@@ -242,6 +268,30 @@ func (s *monitoringSession) applySnapshot(snapshot JVMMonitoringSnapshot, genera
|
||||
return true
|
||||
}
|
||||
|
||||
func FormatMonitoringSampleAutoStoppedWarning(count int) string {
|
||||
return fmt.Sprintf("%s%s:count=%d", monitoringWarningMarkerPrefix, monitoringSampleAutoStoppedWarningKey, count)
|
||||
}
|
||||
|
||||
func ParseMonitoringProviderWarning(warning string) (string, map[string]any, bool) {
|
||||
payload, ok := strings.CutPrefix(strings.TrimSpace(warning), monitoringWarningMarkerPrefix)
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
key, rawParams, ok := strings.Cut(payload, ":")
|
||||
if !ok || key != monitoringSampleAutoStoppedWarningKey {
|
||||
return "", nil, false
|
||||
}
|
||||
name, value, ok := strings.Cut(rawParams, "=")
|
||||
if !ok || name != "count" {
|
||||
return "", nil, false
|
||||
}
|
||||
count, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return key, map[string]any{"count": count}, true
|
||||
}
|
||||
|
||||
func (s *monitoringSession) appendWarning(warning string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -3,6 +3,7 @@ package jvm
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -22,6 +23,8 @@ type blockingMonitoringProvider struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type fakeProviderWithoutMonitoring struct{}
|
||||
|
||||
func (f fakeMonitoringProvider) Mode() string { return ModeJMX }
|
||||
func (f fakeMonitoringProvider) TestConnection(context.Context, connection.ConnectionConfig) error {
|
||||
return nil
|
||||
@@ -53,6 +56,26 @@ func (p *blockingMonitoringProvider) GetMonitoringSnapshot(context.Context, conn
|
||||
return p.snapshot, p.snapshotErr
|
||||
}
|
||||
|
||||
func (f fakeProviderWithoutMonitoring) Mode() string { return ModeJMX }
|
||||
func (f fakeProviderWithoutMonitoring) TestConnection(context.Context, connection.ConnectionConfig) error {
|
||||
return nil
|
||||
}
|
||||
func (f fakeProviderWithoutMonitoring) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]Capability, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeProviderWithoutMonitoring) ListResources(context.Context, connection.ConnectionConfig, string) ([]ResourceSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeProviderWithoutMonitoring) GetValue(context.Context, connection.ConnectionConfig, string) (ValueSnapshot, error) {
|
||||
return ValueSnapshot{}, nil
|
||||
}
|
||||
func (f fakeProviderWithoutMonitoring) PreviewChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ChangePreview, error) {
|
||||
return ChangePreview{}, nil
|
||||
}
|
||||
func (f fakeProviderWithoutMonitoring) ApplyChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ApplyResult, error) {
|
||||
return ApplyResult{}, nil
|
||||
}
|
||||
|
||||
func swapMonitoringProviderFactory(factory func(mode string) (Provider, error)) func() {
|
||||
prev := monitoringProviderFactory
|
||||
monitoringProviderFactory = factory
|
||||
@@ -216,6 +239,60 @@ func TestMonitoringManagerStopMarksSessionStopped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoringManagerReturnsLocalizedErrorsForFixedMonitoringFailures(t *testing.T) {
|
||||
manager := newMonitoringManagerForTest(5)
|
||||
|
||||
_, err := manager.GetHistory("conn-missing", ModeJMX)
|
||||
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.session_not_found", map[string]any{
|
||||
"connectionId": "conn-missing",
|
||||
"providerMode": ModeJMX,
|
||||
})
|
||||
|
||||
err = manager.Stop("conn-missing", ModeAgent)
|
||||
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.session_not_found", map[string]any{
|
||||
"connectionId": "conn-missing",
|
||||
"providerMode": ModeAgent,
|
||||
})
|
||||
|
||||
restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) {
|
||||
return fakeProviderWithoutMonitoring{}, nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
_, err = manager.Start(context.Background(), connection.ConnectionConfig{
|
||||
ID: "conn-monitor",
|
||||
Type: "jvm",
|
||||
Host: "orders.internal",
|
||||
JVM: connection.JVMConfig{
|
||||
PreferredMode: ModeJMX,
|
||||
AllowedModes: []string{ModeJMX},
|
||||
},
|
||||
}, "")
|
||||
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.snapshot_unsupported", map[string]any{
|
||||
"provider": "JMX",
|
||||
})
|
||||
}
|
||||
|
||||
func assertMonitoringLocalizedError(t *testing.T, err error, key string, params map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected localized error %q, got nil", key)
|
||||
}
|
||||
var localized *LocalizedError
|
||||
if !errors.As(err, &localized) {
|
||||
t.Fatalf("expected LocalizedError %q, got %T: %v", key, err, err)
|
||||
}
|
||||
if localized.Key != key {
|
||||
t.Fatalf("expected localized key %q, got %q", key, localized.Key)
|
||||
}
|
||||
for name, expected := range params {
|
||||
if localized.Params[name] != expected {
|
||||
t.Fatalf("expected param %s=%#v, got %#v in %#v", name, expected, localized.Params[name], localized.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoringSessionIgnoresStaleStopFromPreviousSampler(t *testing.T) {
|
||||
session := &monitoringSession{}
|
||||
|
||||
@@ -331,6 +408,15 @@ func TestMonitoringSamplerStopsAfterConsecutiveFailures(t *testing.T) {
|
||||
if len(snapshot.ProviderWarnings) == 0 {
|
||||
t.Fatalf("expected provider warnings to explain sampling failure")
|
||||
}
|
||||
expectedFinalWarning := "__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3"
|
||||
if snapshot.ProviderWarnings[len(snapshot.ProviderWarnings)-1] != expectedFinalWarning {
|
||||
t.Fatalf("expected final warning to be structured, got %#v", snapshot.ProviderWarnings)
|
||||
}
|
||||
for _, warning := range snapshot.ProviderWarnings {
|
||||
if strings.Contains(warning, "监控采样连续失败") {
|
||||
t.Fatalf("expected no localized Chinese warning in manager snapshot, got %#v", snapshot.ProviderWarnings)
|
||||
}
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("sampler did not stop after consecutive failures")
|
||||
|
||||
@@ -13,9 +13,14 @@ type Capability struct {
|
||||
CanWrite bool `json:"canWrite"`
|
||||
CanPreview bool `json:"canPreview"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
reasonKey string
|
||||
DisplayLabel string `json:"displayLabel"`
|
||||
}
|
||||
|
||||
func (c Capability) ReasonLocalizationKey() string {
|
||||
return c.reasonKey
|
||||
}
|
||||
|
||||
type ResourceSummary struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
@@ -69,16 +74,21 @@ type ChangeRequest struct {
|
||||
}
|
||||
|
||||
type ChangePreview struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
|
||||
ConfirmationToken string `json:"confirmationToken,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
BlockingReason string `json:"blockingReason,omitempty"`
|
||||
Allowed bool `json:"allowed"`
|
||||
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
|
||||
ConfirmationToken string `json:"confirmationToken,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
RiskLevel string `json:"riskLevel"`
|
||||
BlockingReason string `json:"blockingReason,omitempty"`
|
||||
blockingReasonKey string
|
||||
Before ValueSnapshot `json:"before"`
|
||||
After ValueSnapshot `json:"after"`
|
||||
}
|
||||
|
||||
func (p ChangePreview) BlockingReasonLocalizationKey() string {
|
||||
return p.blockingReasonKey
|
||||
}
|
||||
|
||||
type ApplyResult struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user