Files
MyGoNavi/internal/jvm/jmx_provider_test.go
Syngnat 58ee269855 🐛 fix(jvm): 收紧 JMX domain allowlist 校验
在 helper runtime 中对直接 ObjectName、资源浏览、变更预览和监控路径统一执行 domain allowlist,阻断默认域别名和空白后缀绕过。
2026-04-28 09:42:29 +08:00

920 lines
28 KiB
Go

package jvm
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
type stubJMXHelper struct {
lastRequest jmxHelperRequest
response jmxHelperResponse
err error
}
func withStubJMXHelper(
t *testing.T,
fn func(context.Context, connection.ConnectionConfig, string, *jmxResourceTarget, *ChangeRequest) (jmxHelperResponse, error),
) {
t.Helper()
prev := jmxHelperRunner
jmxHelperRunner = fn
t.Cleanup(func() {
jmxHelperRunner = prev
})
}
func (s *stubJMXHelper) run(_ context.Context, cfg connection.ConnectionConfig, command string, target *jmxResourceTarget, change *ChangeRequest) (jmxHelperResponse, error) {
s.lastRequest = jmxHelperRequest{
Command: command,
Connection: jmxHelperConnection{
Host: resolveJMXHost(cfg),
Port: resolveJMXPort(cfg),
Username: strings.TrimSpace(cfg.JVM.JMX.Username),
Password: cfg.JVM.JMX.Password,
DomainAllowlist: normalizeJMXAllowlist(cfg.JVM.JMX.DomainAllowlist),
TimeoutSeconds: int(resolveJMXTimeout(cfg).Seconds()),
},
}
if target != nil {
s.lastRequest.Target = helperTargetFromResource(*target)
}
if change != nil {
s.lastRequest.Change = &jmxHelperChangePlan{
Action: change.Action,
Reason: change.Reason,
ExpectedVersion: change.ExpectedVersion,
Payload: change.Payload,
}
}
if s.err != nil {
return jmxHelperResponse{}, s.err
}
return s.response, nil
}
func newJMXProviderTestConfig() connection.ConnectionConfig {
readOnly := false
return connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
Timeout: 5,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
AllowedModes: []string{ModeJMX},
PreferredMode: ModeJMX,
JMX: connection.JVMJMXConfig{
Host: "127.0.0.1",
Port: 9010,
},
},
}
}
func TestJMXProviderListResourcesUsesHelperResponse(t *testing.T) {
helper := &stubJMXHelper{
response: jmxHelperResponse{
Resources: []jmxHelperResource{
{
Kind: "domain",
Name: "java.lang",
CanRead: true,
HasChildren: true,
Domain: "java.lang",
},
},
},
}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
items, err := provider.ListResources(context.Background(), newJMXProviderTestConfig(), "")
if err != nil {
t.Fatalf("ListResources returned error: %v", err)
}
if helper.lastRequest.Command != jmxHelperCommandList {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandList, helper.lastRequest)
}
if len(items) != 1 || items[0].Kind != "domain" || items[0].Path == "" {
t.Fatalf("unexpected resources: %#v", items)
}
}
func TestJMXProviderGetValueUsesHelperSnapshot(t *testing.T) {
helper := &stubJMXHelper{
response: jmxHelperResponse{
Snapshot: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
snapshot, err := provider.GetValue(context.Background(), newJMXProviderTestConfig(), "jmx:/attribute/bean/State")
if err != nil {
t.Fatalf("GetValue returned error: %v", err)
}
if helper.lastRequest.Command != jmxHelperCommandGet {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandGet, helper.lastRequest)
}
if snapshot.ResourceID != "jmx:/attribute/bean/State" || snapshot.Value != "READY" || snapshot.Version == "" {
t.Fatalf("unexpected snapshot: %#v", snapshot)
}
}
func TestJMXProviderGetMonitoringSnapshotUsesHelperMonitorCommand(t *testing.T) {
helper := &stubJMXHelper{
response: jmxHelperResponse{
MonitoringSnapshot: &jmxHelperMonitoringSnapshot{
Point: jmxHelperMonitoringPoint{
Timestamp: 1713945600000,
ThreadCount: 33,
HeapUsedBytes: 536870912,
ProcessCpuLoad: 0.37,
LoadedClassCount: 2048,
ProcessRssBytes: 1610612736,
CommittedVirtualMemoryBytes: 2147483648,
},
RecentGCEvents: []RecentGCEvent{{
Timestamp: 1713945600000,
Name: "G1 Young Generation",
Cause: "G1 Evacuation Pause",
DurationMs: 18,
}},
AvailableMetrics: []string{"thread.count", "heap.used", "class.loading", "memory.rss"},
MissingMetrics: []string{"cpu.system"},
},
},
}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
snapshot, err := provider.GetMonitoringSnapshot(context.Background(), newJMXProviderTestConfig(), nil)
if err != nil {
t.Fatalf("GetMonitoringSnapshot returned error: %v", err)
}
if helper.lastRequest.Command != jmxHelperCommandMonitor {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandMonitor, helper.lastRequest)
}
if snapshot.Point.ThreadCount != 33 || snapshot.Point.HeapUsedBytes != 536870912 || snapshot.Point.LoadedClassCount != 2048 {
t.Fatalf("unexpected monitoring snapshot: %#v", snapshot)
}
if len(snapshot.RecentGCEvents) != 1 || snapshot.RecentGCEvents[0].DurationMs != 18 {
t.Fatalf("unexpected recent gc events: %#v", snapshot.RecentGCEvents)
}
if len(snapshot.MissingMetrics) != 1 || snapshot.MissingMetrics[0] != "cpu.system" {
t.Fatalf("unexpected missing metrics: %#v", snapshot)
}
}
func TestJMXProviderPreviewAndApplyUseHelperPayload(t *testing.T) {
request := ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "jmx:/attribute/bean/State",
Action: "set",
Reason: "repair state",
Payload: map[string]any{
"value": "READY",
},
}
previewHelper := &stubJMXHelper{
response: jmxHelperResponse{
Preview: &jmxHelperPreview{
Allowed: true,
Summary: "preview ok",
RiskLevel: "low",
Before: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "STALE",
},
After: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
},
}
withStubJMXHelper(t, previewHelper.run)
provider := &JMXProvider{}
preview, err := provider.PreviewChange(context.Background(), newJMXProviderTestConfig(), request)
if err != nil {
t.Fatalf("PreviewChange returned error: %v", err)
}
if previewHelper.lastRequest.Command != jmxHelperCommandPreview {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandPreview, previewHelper.lastRequest)
}
if previewHelper.lastRequest.Change.Action != "set" || preview.Summary != "preview ok" {
t.Fatalf("unexpected preview response: %#v / %#v", preview, previewHelper.lastRequest)
}
applyHelper := &stubJMXHelper{
response: jmxHelperResponse{
ApplyResult: &jmxHelperApplyResponse{
Status: "applied",
Message: "updated",
UpdatedValue: &jmxHelperSnapshot{
Kind: "attribute",
Format: "string",
Value: "READY",
},
},
},
}
withStubJMXHelper(t, applyHelper.run)
provider = &JMXProvider{}
result, err := provider.ApplyChange(context.Background(), newJMXProviderTestConfig(), request)
if err != nil {
t.Fatalf("ApplyChange returned error: %v", err)
}
if applyHelper.lastRequest.Command != jmxHelperCommandApply {
t.Fatalf("expected helper command %q, got %#v", jmxHelperCommandApply, applyHelper.lastRequest)
}
if result.Status != "applied" || result.UpdatedValue.Value != "READY" {
t.Fatalf("unexpected apply result: %#v", result)
}
}
func TestJMXProviderWrapsHelperErrors(t *testing.T) {
helper := &stubJMXHelper{err: errors.New("helper failed")}
withStubJMXHelper(t, helper.run)
provider := &JMXProvider{}
_, err := provider.ListResources(context.Background(), newJMXProviderTestConfig(), "")
if err == nil {
t.Fatal("expected helper error")
}
if got := err.Error(); got == "" || !containsAll(got, "list", "helper failed") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestJMXProviderGetValueRejectsUnknownResourcePath(t *testing.T) {
provider := NewJMXProvider()
_, err := provider.GetValue(context.Background(), newJMXProviderTestConfig(), "bad-path")
if err == nil {
t.Fatal("expected invalid resource path to fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "resource path") {
t.Fatalf("expected resource path context, got %v", err)
}
}
func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
if _, err := exec.LookPath("java"); err != nil {
t.Skipf("java 不可用,跳过真实 JMX 集成测试: %v", err)
}
if _, err := exec.LookPath("javac"); err != nil {
t.Skipf("javac 不可用,跳过真实 JMX 集成测试: %v", err)
}
provider := NewJMXProvider()
monitoringProvider, ok := provider.(MonitoringCapableProvider)
if !ok {
t.Fatal("expected JMX provider to implement monitoring snapshots")
}
fixture := startJMXFixture(t)
readOnly := false
cfg := connection.ConnectionConfig{
Type: "jvm",
Host: "127.0.0.1",
Timeout: 8,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
JMX: connection.JVMJMXConfig{
Host: "127.0.0.1",
Port: fixture.port,
DomainAllowlist: []string{"com.gonavi.fixture"},
},
},
}
t.Setenv("GONAVI_JMX_HELPER_CACHE_DIR", filepath.Join(t.TempDir(), "helper-cache"))
waitForTest(t, 20*time.Second, func() error {
return provider.TestConnection(context.Background(), cfg)
})
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 || !caps[0].CanBrowse || !caps[0].CanWrite || !caps[0].CanPreview {
t.Fatalf("unexpected capabilities: %#v", caps)
}
domains, err := provider.ListResources(context.Background(), cfg, "")
if err != nil {
t.Fatalf("ListResources(root) returned error: %v", err)
}
if len(domains) != 1 || domains[0].Name != "com.gonavi.fixture" {
t.Fatalf("unexpected root resources: %#v", domains)
}
mbeans, err := provider.ListResources(context.Background(), cfg, domains[0].Path)
if err != nil {
t.Fatalf("ListResources(domain) returned error: %v", err)
}
if len(mbeans) != 1 {
t.Fatalf("expected one mbean under test domain, got %#v", mbeans)
}
mbean := mbeans[0]
blockedDomainPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindDomain,
Domain: "java.lang",
})
_, err = provider.ListResources(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected list on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain get context, got %v", err)
}
blockedMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "java.lang:type=Memory",
})
_, err = provider.ListResources(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected list on blocked domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected direct mbean get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean get context, got %v", err)
}
blockedAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "java.lang:type=Memory",
Attribute: "HeapMemoryUsage",
})
_, err = provider.GetValue(context.Background(), cfg, blockedAttributePath)
if err == nil {
t.Fatal("expected direct attribute get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute get context, got %v", err)
}
blockedOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "java.lang:type=Memory",
Operation: "gc",
})
_, err = provider.GetValue(context.Background(), cfg, blockedOperationPath)
if err == nil {
t.Fatal("expected direct operation get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation get context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作预览",
})
if err == nil {
t.Fatal("expected preview on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作调用",
})
if err == nil {
t.Fatal("expected apply on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation apply context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性预览",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected preview on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性修改",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected apply on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute apply context, got %v", err)
}
defaultDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, defaultDomainMBeanPath)
if err == nil {
t.Fatal("expected list on default domain alias mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias mbean list context, got %v", err)
}
defaultDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, defaultDomainAttributePath)
if err == nil {
t.Fatal("expected get on default domain alias attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias attribute get context, got %v", err)
}
defaultDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作预览",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected preview on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作调用",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected apply on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation apply context, got %v", err)
}
whitespaceDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, whitespaceDomainMBeanPath)
if err == nil {
t.Fatal("expected list on whitespace-suffixed domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain mbean list context, got %v", err)
}
whitespaceDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, whitespaceDomainAttributePath)
if err == nil {
t.Fatal("expected get on whitespace-suffixed domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain attribute get context, got %v", err)
}
whitespaceDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作预览",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected preview on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作调用",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected apply on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation apply context, got %v", err)
}
_, err = monitoringProvider.GetMonitoringSnapshot(context.Background(), cfg, nil)
if err == nil {
t.Fatal("expected monitor on blocked domain allowlist to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain monitor context, got %v", err)
}
monitoringCfg := cfg
monitoringCfg.JVM.JMX.DomainAllowlist = []string{"com.gonavi.fixture", "java.lang"}
monitoringSnapshot, err := monitoringProvider.GetMonitoringSnapshot(context.Background(), monitoringCfg, nil)
if err != nil {
t.Fatalf("expected monitor with java.lang allowlist to succeed: %v", err)
}
if monitoringSnapshot.Point.Timestamp <= 0 {
t.Fatalf("unexpected monitor snapshot point: %#v", monitoringSnapshot.Point)
}
children, err := provider.ListResources(context.Background(), cfg, mbean.Path)
if err != nil {
t.Fatalf("ListResources(mbean) returned error: %v", err)
}
modeAttr := findResourceByName(t, children, "Mode")
passwordAttr := findResourceByName(t, children, "Password")
apiKeyAttr := findResourceByName(t, children, "ApiKey")
lastInvocationAttr := findResourceByName(t, children, "LastInvocation")
resizeOp := findResourceByName(t, children, "resize(int,boolean)")
passwordSnapshot, err := provider.GetValue(context.Background(), cfg, passwordAttr.Path)
if err != nil {
t.Fatalf("GetValue(password) returned error: %v", err)
}
if !passwordSnapshot.Sensitive {
t.Fatalf("expected password snapshot to be sensitive: %#v", passwordSnapshot)
}
for _, action := range passwordSnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "secret-token" {
t.Fatalf("sensitive payload example leaked raw password: %#v", action.PayloadExample)
}
}
apiKeySnapshot, err := provider.GetValue(context.Background(), cfg, apiKeyAttr.Path)
if err != nil {
t.Fatalf("GetValue(api key) returned error: %v", err)
}
if !apiKeySnapshot.Sensitive {
t.Fatalf("expected api key snapshot to be sensitive: %#v", apiKeySnapshot)
}
for _, action := range apiKeySnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "api-key-secret" {
t.Fatalf("sensitive payload example leaked raw api key: %#v", action.PayloadExample)
}
}
modeBefore, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode before) returned error: %v", err)
}
if modeBefore.Value != "warm" {
t.Fatalf("expected initial mode warm, got %#v", modeBefore)
}
if strings.TrimSpace(modeBefore.Version) == "" {
t.Fatalf("expected initial mode version, got %#v", modeBefore)
}
attrPreview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "切换缓存模式",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "hot",
},
})
if err != nil {
t.Fatalf("PreviewChange(attribute) returned error: %v", err)
}
if !attrPreview.Allowed {
t.Fatalf("expected attribute preview allowed, got %#v", attrPreview)
}
if attrPreview.Before.Value != "warm" || attrPreview.After.Value != "hot" {
t.Fatalf("unexpected attribute preview diff: %#v", attrPreview)
}
attrApply, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "切换缓存模式",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "hot",
},
})
if err != nil {
t.Fatalf("ApplyChange(attribute) returned error: %v", err)
}
if strings.TrimSpace(attrApply.Status) == "" || attrApply.UpdatedValue.Value != "hot" {
t.Fatalf("unexpected attribute apply result: %#v", attrApply)
}
modeAfter, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode after) returned error: %v", err)
}
if modeAfter.Value != "hot" {
t.Fatalf("expected mode hot after apply, got %#v", modeAfter)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: modeAttr.Path,
Action: "update",
Reason: "尝试使用过期版本",
ExpectedVersion: modeBefore.Version,
Payload: map[string]any{
"value": "cold",
},
})
if err == nil {
t.Fatal("expected stale version apply to fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "version") {
t.Fatalf("expected version mismatch context, got %v", err)
}
opPreview, err := provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: resizeOp.Path,
Action: "invoke",
Reason: "执行 resize 操作",
Payload: map[string]any{
"args": []any{128, true},
},
})
if err != nil {
t.Fatalf("PreviewChange(operation) returned error: %v", err)
}
if !opPreview.Allowed || !strings.Contains(opPreview.Summary, "resize") {
t.Fatalf("unexpected operation preview: %#v", opPreview)
}
opApply, err := provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: resizeOp.Path,
Action: "invoke",
Reason: "执行 resize 操作",
Payload: map[string]any{
"args": []any{128, true},
},
})
if err != nil {
t.Fatalf("ApplyChange(operation) returned error: %v", err)
}
if strings.TrimSpace(opApply.Status) == "" {
t.Fatalf("expected operation apply status, got %#v", opApply)
}
lastInvocation, err := provider.GetValue(context.Background(), cfg, lastInvocationAttr.Path)
if err != nil {
t.Fatalf("GetValue(last invocation) returned error: %v", err)
}
if lastInvocation.Value != "capacity=128,enabled=true" {
t.Fatalf("unexpected operation side effect snapshot: %#v", lastInvocation)
}
}
type jmxFixtureProcess struct {
port int
cmd *exec.Cmd
}
func startJMXFixture(t *testing.T) jmxFixtureProcess {
t.Helper()
javaBin, err := exec.LookPath("java")
if err != nil {
t.Fatalf("look up java failed: %v", err)
}
javacBin, err := exec.LookPath("javac")
if err != nil {
t.Fatalf("look up javac failed: %v", err)
}
classesDir := filepath.Join(t.TempDir(), "fixture-classes")
sourceRoot := filepath.Join(testRepoRoot(t), "internal", "jvm", "testdata", "jmxfixture", "src")
javaFiles, err := filepath.Glob(filepath.Join(sourceRoot, "com", "gonavi", "fixture", "*.java"))
if err != nil {
t.Fatalf("glob fixture sources failed: %v", err)
}
if len(javaFiles) == 0 {
t.Fatalf("expected fixture java files under %s", sourceRoot)
}
compileCmd := exec.Command(javacBin, append([]string{"-d", classesDir}, javaFiles...)...)
output, err := compileCmd.CombinedOutput()
if err != nil {
t.Fatalf("compile fixture failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
port := reserveTCPPort(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
cmd := exec.CommandContext(ctx, javaBin,
fmt.Sprintf("-Dcom.sun.management.jmxremote.port=%d", port),
fmt.Sprintf("-Dcom.sun.management.jmxremote.rmi.port=%d", port),
"-Dcom.sun.management.jmxremote.authenticate=false",
"-Dcom.sun.management.jmxremote.ssl=false",
"-Dcom.sun.management.jmxremote.local.only=false",
"-Dcom.sun.management.jmxremote.host=127.0.0.1",
"-Djava.rmi.server.hostname=127.0.0.1",
"-cp", classesDir,
"com.gonavi.fixture.JMXTestServer",
)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("fixture stdout pipe failed: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start fixture failed: %v", err)
}
t.Cleanup(func() {
cancel()
_ = cmd.Wait()
})
ready := make(chan error, 1)
go func() {
line, readErr := bufio.NewReader(stdout).ReadString('\n')
if readErr != nil {
ready <- fmt.Errorf("fixture readiness read failed: %w", readErr)
return
}
if strings.TrimSpace(line) != "READY" {
ready <- fmt.Errorf("unexpected fixture readiness line: %q", strings.TrimSpace(line))
return
}
ready <- nil
}()
select {
case err := <-ready:
if err != nil {
t.Fatalf("wait fixture ready failed: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("fixture did not become ready within 20s")
}
waitForTest(t, 10*time.Second, func() error {
conn, dialErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
if dialErr != nil {
return dialErr
}
_ = conn.Close()
return nil
})
return jmxFixtureProcess{port: port, cmd: cmd}
}
func waitForTest(t *testing.T, timeout time.Duration, fn func() error) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
if err := fn(); err == nil {
return
} else {
lastErr = err
}
time.Sleep(200 * time.Millisecond)
}
if lastErr == nil {
lastErr = errors.New("condition not satisfied before timeout")
}
t.Fatalf("condition not met within %s: %v", timeout, lastErr)
}
func reserveTCPPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve TCP port failed: %v", err)
}
defer listener.Close()
addr, ok := listener.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("unexpected TCP addr type: %T", listener.Addr())
}
return addr.Port
}
func findResourceByName(t *testing.T, items []ResourceSummary, name string) ResourceSummary {
t.Helper()
for _, item := range items {
if item.Name == name {
return item
}
}
t.Fatalf("resource %q not found in %#v", name, items)
return ResourceSummary{}
}
func testRepoRoot(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
}
func containsAll(source string, fragments ...string) bool {
for _, fragment := range fragments {
if !strings.Contains(source, fragment) {
return false
}
}
return true
}