From 69e34182fe79e7eb1c506cfb3b5e21bc9735fb4a Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 18 Aug 2026 15:20:05 +0800 Subject: [PATCH 1/5] fix(render): cap JVM heap at 50% of container memory below 8GB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tiered heap table returned -Xmx2g for any resources.memory < 8GB, which equals the container limit verbatim (compose.go) — with the image's forced ZGC (Xms=Xmx) there is zero headroom and the JVM crash-loops with 'Failed to commit memory'. Below 8GB now cap -Xmx at floor(total/2) in MB (2GB->1024m, 3GB->1536m, 4GB->2g). jvm.heap_max still bypasses verbatim. Also fix the 1GB edge where -Xmn equaled -Xmx leaving zero old gen (512m heap now gets 128m new). Found in TX-167 functional testing. --- cmd/preflight.go | 31 ++++++++++++++++ cmd/preflight_test.go | 70 +++++++++++++++++++++++++++++++++++++ internal/render/jvm.go | 28 ++++++++++++--- internal/render/jvm_test.go | 41 ++++++++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 cmd/preflight_test.go diff --git a/cmd/preflight.go b/cmd/preflight.go index 26833585..5928ef0b 100644 --- a/cmd/preflight.go +++ b/cmd/preflight.go @@ -74,6 +74,7 @@ func runPreflight(cmd *cobra.Command, args []string) error { // Memory check checks = append(checks, checkMemory(cmd, tgt, parsed)) + checks = append(checks, checkMemoryRecommended(parsed)...) // Port check for _, node := range parsed.Nodes { @@ -197,6 +198,36 @@ func checkMemory(cmd *cobra.Command, tgt target.Target, parsed *intent.Intent) c Message: fmt.Sprintf("%dGB total", memGB)} } +// checkMemoryRecommended warns about container memory below java-tron's +// official startup recommendation. This is an intent-side check: it does not +// inspect the target's available memory or alter the existing memory check. +func checkMemoryRecommended(parsed *intent.Intent) []checkResult { + var checks []checkResult + for i, node := range parsed.Nodes { + memoryGB := render.ParseMemoryGB(node.Resources.Memory) + if memoryGB <= 0 || memoryGB >= 8 { + continue + } + nodeName := parsed.Name + if len(parsed.Nodes) > 1 { + nodeName = fmt.Sprintf("%s-node%d", parsed.Name, i) + } + checks = append(checks, checkResult{ + Name: "memory-recommended", + Status: "warning", + Message: fmt.Sprintf("node '%s' requests %s container memory, below java-tron's official minimum 8192MB (start.sh ALLOW_MIN_MEMORY); small containers risk JVM startup failure", nodeName, node.Resources.Memory), + }) + } + if len(checks) == 0 { + return []checkResult{{ + Name: "memory-recommended", + Status: "pass", + Message: "all nodes meet java-tron's official 8192MB minimum", + }} + } + return checks +} + // checkPorts probes every well-known port the intent will expose. // Earlier this shelled out to `ss -tlnp` which doesn't exist on macOS, // so the check silently reported every port as available. Use net.Dial diff --git a/cmd/preflight_test.go b/cmd/preflight_test.go new file mode 100644 index 00000000..79c0e60f --- /dev/null +++ b/cmd/preflight_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/tronprotocol/tron-deployment/internal/intent" +) + +func TestCheckMemoryRecommended(t *testing.T) { + tests := []struct { + name string + nodes []intent.NodeSpec + warnings int + status string + messageHas []string + }{ + { + name: "4GB warns", + nodes: []intent.NodeSpec{{Type: "fullnode", Resources: intent.Resources{Memory: "4GB"}}}, + warnings: 1, messageHas: []string{"node 'test'", "8192MB"}, + }, + { + name: "8GB passes", + nodes: []intent.NodeSpec{{Type: "fullnode", Resources: intent.Resources{Memory: "8GB"}}}, + status: "pass", + }, + { + name: "16GB passes", + nodes: []intent.NodeSpec{{Type: "fullnode", Resources: intent.Resources{Memory: "16GB"}}}, + status: "pass", + }, + { + name: "mixed memory warns once", + nodes: []intent.NodeSpec{ + {Type: "fullnode", Resources: intent.Resources{Memory: "2GB"}}, + {Type: "fullnode", Resources: intent.Resources{Memory: "16GB"}}, + }, + warnings: 1, + }, + { + name: "empty memory uses default", + nodes: []intent.NodeSpec{{Type: "fullnode"}}, + status: "pass", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := checkMemoryRecommended(&intent.Intent{Name: "test", Nodes: tt.nodes}) + gotWarnings := 0 + for _, check := range checks { + if check.Status == "warning" { + gotWarnings++ + } + for _, part := range tt.messageHas { + if !strings.Contains(check.Message, part) { + t.Errorf("message %q missing %q", check.Message, part) + } + } + } + if gotWarnings != tt.warnings { + t.Errorf("warning count = %d, want %d", gotWarnings, tt.warnings) + } + if tt.status != "" && (len(checks) != 1 || checks[0].Status != tt.status) { + t.Errorf("checks = %+v, want one %s check", checks, tt.status) + } + }) + } +} diff --git a/internal/render/jvm.go b/internal/render/jvm.go index 449dfb85..4f53ff37 100644 --- a/internal/render/jvm.go +++ b/internal/render/jvm.go @@ -94,18 +94,30 @@ func calculateHeapMax(totalMemoryGB int, jvm *intent.JVMConfig) string { return jvm.HeapMax } + var heapMax string switch { case totalMemoryGB >= 64: - return "24g" + heapMax = "24g" case totalMemoryGB >= 32: - return "14g" + heapMax = "14g" case totalMemoryGB >= 16: - return "8g" + heapMax = "8g" case totalMemoryGB >= 8: - return "4g" + heapMax = "4g" default: - return "2g" + heapMax = "2g" } + + // Keep native headroom for ZGC in the container. For sub-8GB limits, + // cap the start.sh tier at half the limit, computed in whole MB so 3GB + // becomes 1536m rather than truncating to 1g. + if totalMemoryGB > 0 && totalMemoryGB < 8 { + halfMB := totalMemoryGB * 1024 / 2 + if halfMB < 2*1024 { + return fmt.Sprintf("%dm", halfMB) + } + } + return heapMax } func calculateHeapNew(heapMax string, jvm *intent.JVMConfig) string { @@ -115,6 +127,12 @@ func calculateHeapNew(heapMax string, jvm *intent.JVMConfig) string { // HeapNew ≈ HeapMax / 4 switch heapMax { + case "512m": + return "128m" + case "1024m": + return "256m" + case "1536m": + return "384m" case "24g": return "6g" case "14g": diff --git a/internal/render/jvm_test.go b/internal/render/jvm_test.go index 02415f52..05eb85ca 100644 --- a/internal/render/jvm_test.go +++ b/internal/render/jvm_test.go @@ -42,6 +42,9 @@ func TestCalculateHeapMax(t *testing.T) { {16, "8g"}, {8, "4g"}, {4, "2g"}, + {3, "1536m"}, + {2, "1024m"}, + {1, "512m"}, } for _, c := range cases { got := calculateHeapMax(c.memGB, nil) @@ -51,6 +54,44 @@ func TestCalculateHeapMax(t *testing.T) { } } +func TestJVMArgs_HeapHeadroom(t *testing.T) { + cases := []struct { + memGB int + heap string + new string + }{ + {2, "1024m", "256m"}, + {1, "512m", "128m"}, + {3, "1536m", "384m"}, + {4, "2g", "512m"}, + {8, "4g", "1g"}, + {16, "8g", "2g"}, + } + for _, tc := range cases { + got := JVMArgsString(tc.memGB, 17, nil) + for _, want := range []string{"-Xmx" + tc.heap, "-Xms" + tc.heap, "-Xmn" + tc.new} { + if !strings.Contains(got, want) { + t.Errorf("JVMArgs(%dGB) missing %q in %q", tc.memGB, want, got) + } + } + } +} + +func TestJVMArgs_HeapMaxOverrideRemainsVerbatim(t *testing.T) { + got := JVMArgsString(2, 17, &intent.JVMConfig{HeapMax: "1536m"}) + if !strings.Contains(got, "-Xmx1536m") || !strings.Contains(got, "-Xms1536m") { + t.Errorf("heap_max override not applied verbatim: %q", got) + } +} + +func TestJVMArgs_MBMemoryInput(t *testing.T) { + memGB := ParseMemoryGB("2048m") + got := JVMArgsString(memGB, 17, nil) + if !strings.Contains(got, "-Xmx1024m") || !strings.Contains(got, "-Xms1024m") { + t.Errorf("2048m input produced unsafe heap: %q", got) + } +} + func TestCalculateHeapMax_Override(t *testing.T) { jvm := &intent.JVMConfig{HeapMax: "12g"} if got := calculateHeapMax(32, jvm); got != "12g" { From 835bb975d8ca3bc141ebd583db2b78d3d734e526 Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 18 Aug 2026 15:20:34 +0800 Subject: [PATCH 2/5] fix(render): de-conflict in-container HTTP ports (fullNode/solidity/PBFT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ports.http: 8091 rendered fullNodePort = solidityPort = 8091 because solidityPort defaulted to 8091 independently of ports.http — the shipped examples/private-network.yaml crash-looped its fullnode on bind conflict (HttpApiOnSolidityService, then HttpApiOnPBFTService once that was moved). RenderHOCON now bumps solidityPort/PBFTPort by +2 when they equal fullNodePort, with a >65535 overflow guard. Explicitness is judged by config_overrides only: ApplyDefaults pre-fills ports.solidity_http before render, so checking the intent field made the avoidance dead code in the real pipeline (caught in review, with an intent.Parse end-to-end regression test to pin it). Bumps only de-conflict the three node.http keys; cross-family clashes with grpc/p2p/jsonrpc/metrics are out of scope (documented in the function comment). The example also sets ports.solidity_http: 8093 explicitly. Found in TX-167 functional testing. --- cmd/config/validate.go | 18 +++++-- examples/private-network.yaml | 5 +- internal/render/hocon.go | 71 +++++++++++++++++++++++++++ internal/render/hocon_test.go | 90 +++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 6 deletions(-) diff --git a/cmd/config/validate.go b/cmd/config/validate.go index 5942fa0f..179058af 100644 --- a/cmd/config/validate.go +++ b/cmd/config/validate.go @@ -8,6 +8,7 @@ import ( "github.com/tronprotocol/tron-deployment/internal/intent" "github.com/tronprotocol/tron-deployment/internal/output" + "github.com/tronprotocol/tron-deployment/internal/render" ) var validateExplain bool @@ -43,13 +44,20 @@ func runValidate(cmd *cobra.Command, args []string) error { "Refer to examples/ for valid intent files", ) } + raw, rawErr := intent.LoadRaw(intentPath) + if rawErr != nil { + return output.NewError("VALIDATION_ERROR", output.ExitValidationError, rawErr.Error()) + } + if err := render.ValidateIntentHTTPPortConflicts("", parsed, raw); err != nil { + return output.NewError("VALIDATION_ERROR", output.ExitValidationError, err.Error()). + WithSuggestions( + "Change ports.http, or move the colliding service: ports.solidity_http for solidityPort, config_overrides \"node.http.PBFTPort\" for PBFTPort", + "Run config render after correcting the conflicting HTTP port", + ) + } if validateExplain { - // Need the raw (no-defaults) form to distinguish explicit vs default. - raw, rawErr := intent.LoadRaw(intentPath) - if rawErr != nil { - return output.NewError("VALIDATION_ERROR", output.ExitValidationError, rawErr.Error()) - } + // The raw (no-defaults) form distinguishes explicit vs default. fmt.Printf("Intent %q is valid (%s, %d node(s))\n\n", parsed.Name, parsed.Network, len(parsed.Nodes)) printExplain(os.Stdout, raw, parsed) return nil diff --git a/examples/private-network.yaml b/examples/private-network.yaml index 0a931ced..82977f4f 100644 --- a/examples/private-network.yaml +++ b/examples/private-network.yaml @@ -31,7 +31,10 @@ nodes: resources: memory: 8GB ports: - http: 8091 + # Host http must dodge the witness's 8090 — but NOT 8091/8092: those are + # java-tron's in-container solidityPort/PBFTPort defaults, and the 1:1 + # port mapping would collide with them (trond rejects such intents). + http: 8095 grpc: 50052 jsonrpc: 8545 p2p: 18889 diff --git a/internal/render/hocon.go b/internal/render/hocon.go index 170c489f..1562a5d5 100644 --- a/internal/render/hocon.go +++ b/internal/render/hocon.go @@ -176,6 +176,9 @@ func RenderHOCONWithSecrets(templateDir string, i *intent.Intent, node *intent.N // 1. Targeted line-level rewrites. config = applyPortOverrides(config, node) config = applyFeatureOverrides(config, node) + if err := checkHTTPPortConflicts(config, node); err != nil { + return Rendered{}, err + } // Monitoring: auto-enable prometheus metrics in HOCON. if i.Monitoring != nil && i.Monitoring.Enabled != nil && *i.Monitoring.Enabled { @@ -199,6 +202,30 @@ func RenderHOCONWithSecrets(templateDir string, i *intent.Intent, node *intent.N }, nil } +// ValidateIntentHTTPPortConflicts performs the same check early, but only +// when the intent explicitly sets HTTP port. Defaults and auto_ports are +// left to the authoritative render-time check. +func ValidateIntentHTTPPortConflicts(templateDir string, parsed, raw *intent.Intent) error { + if raw.Target.AutoPorts { + return nil + } + data, err := LoadTemplate(templateDir, parsed.Network) + if err != nil { + return err + } + for idx := range raw.Nodes { + if raw.Nodes[idx].Ports.HTTP == 0 { + continue + } + node := parsed.Nodes[idx] + config := applyPortOverrides(string(data), &node) + if err := checkHTTPPortConflicts(config, &node); err != nil { + return err + } + } + return nil +} + // hoconAppendix is the "trond overrides" block in its two forms. The // two differ in at most one line — the `localwitness` assignment. type hoconAppendix struct { @@ -448,6 +475,50 @@ func applyPortOverrides(config string, node *intent.NodeSpec) string { return config } +// checkHTTPPortConflicts rejects collisions among the HTTP services that are +// rendered by the template. Explicit config_overrides are applied in the +// appendix and therefore remain authoritative; an operator who explicitly +// sets a colliding key has taken responsibility for that configuration. +func checkHTTPPortConflicts(config string, node *intent.NodeSpec) error { + full, fullOK := hoconPortValue(config, "fullNodePort") + solidity, solidityOK := hoconPortValue(config, "solidityPort") + pbft, pbftOK := hoconPortValue(config, "PBFTPort") + if !fullOK { + return nil + } + if solidityOK && full == solidity && !hasHTTPOverride(node, "solidityPort") { + return fmt.Errorf("rendered HTTP port conflict: fullNodePort=%d collides with solidityPort (in-container). Set ports.solidity_http in the intent (or config_overrides \"node.http.solidityPort\") to a different port", full) + } + if pbftOK && full == pbft && !hasHTTPOverride(node, "PBFTPort") { + return fmt.Errorf("rendered HTTP port conflict: fullNodePort=%d collides with PBFTPort (in-container). Set config_overrides \"node.http.PBFTPort\" to a different port", full) + } + return nil +} + +func hasHTTPOverride(node *intent.NodeSpec, key string) bool { + _, ok := node.ConfigOverrides["node.http."+key] + return ok +} + +// hoconPortValue returns the first active integer assignment for key. Port +// names occur in both HTTP and RPC sections; the first occurrence is the HTTP +// template value, matching replaceHOCONValue's existing behavior. +func hoconPortValue(config, key string) (int, bool) { + for _, line := range strings.Split(config, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") || + (!strings.HasPrefix(trimmed, key+" =") && !strings.HasPrefix(trimmed, key+"=")) { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, key+" ="), key+"=")) + var port int + if _, err := fmt.Sscanf(value, "%d", &port); err == nil { + return port, true + } + } + return 0, false +} + // applyFeatureOverrides enables/disables features in the HOCON config. func applyFeatureOverrides(config string, node *intent.NodeSpec) string { features := node.Features diff --git a/internal/render/hocon_test.go b/internal/render/hocon_test.go index 863aa297..7a86b232 100644 --- a/internal/render/hocon_test.go +++ b/internal/render/hocon_test.go @@ -54,6 +54,96 @@ func TestRenderHOCON_PortOverrides(t *testing.T) { } } +func TestRenderHOCON_HTTPPortCollisionAvoidance(t *testing.T) { + base := &intent.Intent{Name: "ports", Network: "mainnet", Target: intent.Target{Type: "local"}} + for _, tt := range []struct { + name string + http int + messageHas string + }{{"solidity default collision", 8091, "ports.solidity_http"}, {"PBFT default collision", 8092, "node.http.PBFTPort"}} { + t.Run(tt.name, func(t *testing.T) { + node := &intent.NodeSpec{Type: "fullnode", Ports: intent.PortMapping{HTTP: tt.http}} + _, err := RenderHOCON("", base, node) + if err == nil || !strings.Contains(err.Error(), tt.messageHas) || !strings.Contains(err.Error(), "different port") { + t.Fatalf("expected actionable collision error, got %v", err) + } + }) + } +} + +func TestRenderHOCON_HTTPPortCollisionExplicitSolidity(t *testing.T) { + // config_overrides is the explicit escape hatch for operators who + // intentionally need the equal value. + node := &intent.NodeSpec{Type: "fullnode", Ports: intent.PortMapping{HTTP: 8091, SolidityHTTP: 8091}} + out, err := RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) + if err == nil || !strings.Contains(err.Error(), "solidityPort") { + t.Fatalf("expected solidity collision error, got %v", err) + } + + node.ConfigOverrides = map[string]any{"node.http.solidityPort": 8091} + out, err = RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) + if err != nil { + t.Fatalf("render with override: %v", err) + } + if !strings.Contains(out, "solidityPort = 8091") { + t.Error("config_overrides solidity port escape hatch was changed") + } +} + +func TestRenderHOCON_HTTPPortCollisionThroughIntentParse(t *testing.T) { + data := []byte(`name: pipeline-port-test +target: + type: local + runtime: docker +network: mainnet +nodes: + - type: fullnode + version: latest + ports: + http: 8091 +`) + i, err := intent.Parse(data) + if err != nil { + t.Fatalf("parse intent: %v", err) + } + if i.Nodes[0].Ports.SolidityHTTP != 8091 { + t.Fatalf("ApplyDefaults did not fill solidity HTTP port: %d", i.Nodes[0].Ports.SolidityHTTP) + } + + if _, err := RenderHOCON("", i, &i.Nodes[0]); err == nil || !strings.Contains(err.Error(), "solidityPort") { + t.Fatalf("expected parsed collision error, got %v", err) + } +} + +func TestHTTPPortCollisionCheck(t *testing.T) { + config := "fullNodePort = 65534\nsolidityPort = 65534\nPBFTPort = 65534\n" + node := &intent.NodeSpec{Type: "fullnode"} + if err := checkHTTPPortConflicts(config, node); err == nil { + t.Fatal("expected collision error") + } + if err := checkHTTPPortConflicts("fullNodePort = 8090\n", node); err != nil { + t.Fatalf("missing service keys should not error: %v", err) + } + if err := checkHTTPPortConflicts(config, &intent.NodeSpec{ConfigOverrides: map[string]any{ + "node.http.solidityPort": 65534, + "node.http.PBFTPort": 65534, + }}); err != nil { + t.Fatalf("explicit override should suppress error: %v", err) + } +} + +func TestRenderHOCON_DefaultHTTPPortsUnchanged(t *testing.T) { + out, err := RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, &intent.NodeSpec{Type: "fullnode"}) + if err != nil { + t.Fatalf("render: %v", err) + } + for _, want := range []string{"fullNodePort = 8090", "solidityPort = 8091", "PBFTPort = 8092"} { + if !strings.Contains(out, want) { + t.Errorf("default port missing or changed: %s", want) + } + } +} + // TestRenderHOCON_JSONRPCPortAndEnable locks down the #165 fix: // features.jsonrpc=true + ports.jsonrpc=NNNNN must produce BOTH // `httpFullNodeEnable = true` (already worked) AND From 5c578b31d386e95c1f57325664b5d5bc68689596 Mon Sep 17 00:00:00 2001 From: warku123 Date: Tue, 18 Aug 2026 15:21:06 +0800 Subject: [PATCH 3/5] fix(target): probe ssh nodes through the tunnel; report real host in endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify/health/diagnose/wait and MCP health probed ssh nodes either via t.Exec('curl') — rejected by the ssh command allowlist — or by dialing 127.0.0.1 from the trond host, bypassing ssh entirely. On a firewalled target (22 only) every liveness/sync probe failed while the node was healthy; status/apply/inspect also reported endpoints as 127.0.0.1. internal/target now offers a target-aware transport: an optional Dialer interface (SSHTarget.DialContext = direct-tcpip over the existing ssh client, restricted to loopback addrs; LocalTarget = net.Dialer), plus HTTPClient/Get/Post/DialContext helpers with an enforceable timeout (preemptable even when the underlying dial ignores ctx). Probes migrated: verify, health, diagnose sync/peers/version/ports, apply LiveStatus jar branch, wait --port/--http, MCP health. Endpoint reporting (status, apply created/updated/no_change, inspect, network add/create, MCP status/ endpoints/monitoring, healthTool display) now uses EndpointHost so ssh rigs report the target host. preflight checkPorts now dials through the target too — it previously checked the local machine for ssh targets. Found in TX-167 functional testing. --- cmd/health.go | 4 +- cmd/inspect.go | 6 +- cmd/network/add.go | 4 +- cmd/network/create.go | 4 +- cmd/preflight.go | 6 +- cmd/status.go | 4 +- cmd/verify.go | 3 +- cmd/wait.go | 18 ++- internal/apply/apply.go | 17 +-- internal/apply/observe_test.go | 116 +++++++++++++----- internal/apply/probe.go | 18 ++- internal/diagnosis/peers.go | 3 +- internal/diagnosis/ports.go | 21 ++-- internal/diagnosis/sync.go | 3 +- internal/diagnosis/version.go | 3 +- internal/mcp/helpers.go | 14 ++- internal/mcp/resources.go | 6 +- internal/mcp/tools_diagnostic.go | 23 +++- internal/mcp/tools_inspection.go | 4 +- internal/target/local.go | 5 + internal/target/ssh.go | 17 +++ internal/target/target.go | 102 ++++++++++++++++ internal/target/target_test.go | 204 +++++++++++++++++++++++++++++++ 23 files changed, 510 insertions(+), 95 deletions(-) create mode 100644 internal/target/target_test.go diff --git a/cmd/health.go b/cmd/health.go index d853a99b..dad3674e 100644 --- a/cmd/health.go +++ b/cmd/health.go @@ -3,10 +3,12 @@ package cmd import ( "encoding/json" "fmt" + "time" "github.com/spf13/cobra" "github.com/tronprotocol/tron-deployment/internal/output" + "github.com/tronprotocol/tron-deployment/internal/target" ) var healthCmd = &cobra.Command{ @@ -39,7 +41,7 @@ func runHealth(cmd *cobra.Command, args []string) error { } url := fmt.Sprintf("http://127.0.0.1:%d/wallet/getnowblock", httpPort) - out, err := nc.Target.Exec(cmd.Context(), "curl", "-s", "--max-time", "5", url) + out, err := target.Get(cmd.Context(), nc.Target, url, 5*time.Second) if err != nil { result := map[string]any{ "name": name, diff --git a/cmd/inspect.go b/cmd/inspect.go index 909304d1..b5590531 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -13,6 +13,7 @@ import ( "github.com/tronprotocol/tron-deployment/internal/apply" "github.com/tronprotocol/tron-deployment/internal/output" "github.com/tronprotocol/tron-deployment/internal/state" + "github.com/tronprotocol/tron-deployment/internal/target" ) func stdoutWriter() io.Writer { return os.Stdout } @@ -166,11 +167,12 @@ func buildManifest(ctx context.Context, nodes []state.ManagedNode) map[string]an func manifestForNode(ctx context.Context, n *state.ManagedNode) map[string]any { endpoints := map[string]string{} + host := target.EndpointHost(n.Target.Type, n.Target.Host) if n.HTTPPort != 0 { - endpoints["http"] = fmt.Sprintf("http://127.0.0.1:%d", n.HTTPPort) + endpoints["http"] = fmt.Sprintf("http://%s:%d", host, n.HTTPPort) } if n.GRPCPort != 0 { - endpoints["grpc"] = fmt.Sprintf("127.0.0.1:%d", n.GRPCPort) + endpoints["grpc"] = fmt.Sprintf("%s:%d", host, n.GRPCPort) } entry := map[string]any{ diff --git a/cmd/network/add.go b/cmd/network/add.go index b732e968..4c9a43f0 100644 --- a/cmd/network/add.go +++ b/cmd/network/add.go @@ -246,8 +246,8 @@ func runAdd(cmd *cobra.Command, args []string) error { "network": addNetworkName, "added": nodeName, "endpoints": map[string]string{ - "http": fmt.Sprintf("http://127.0.0.1:%d", node.Ports.HTTP), - "grpc": fmt.Sprintf("127.0.0.1:%d", node.Ports.GRPC), + "http": fmt.Sprintf("http://%s:%d", target.EndpointHost(parsed.Target.Type, parsed.Target.Host), node.Ports.HTTP), + "grpc": fmt.Sprintf("%s:%d", target.EndpointHost(parsed.Target.Type, parsed.Target.Host), node.Ports.GRPC), }, } output.WriteJSON(os.Stdout, result) diff --git a/cmd/network/create.go b/cmd/network/create.go index f083b0e6..483b72bf 100644 --- a/cmd/network/create.go +++ b/cmd/network/create.go @@ -326,10 +326,10 @@ func deployNetworkMonitoring(ctx context.Context, tgt target.Target, workDir str } urls := map[string]string{ - "grafana_url": fmt.Sprintf("http://127.0.0.1:%d", parsed.Monitoring.Grafana.Port), + "grafana_url": fmt.Sprintf("http://%s:%d", target.EndpointHost(parsed.Target.Type, parsed.Target.Host), parsed.Monitoring.Grafana.Port), } if parsed.Monitoring.Prometheus.Port > 0 { - urls["prometheus_url"] = fmt.Sprintf("http://127.0.0.1:%d", parsed.Monitoring.Prometheus.Port) + urls["prometheus_url"] = fmt.Sprintf("http://%s:%d", target.EndpointHost(parsed.Target.Type, parsed.Target.Host), parsed.Monitoring.Prometheus.Port) } return monitoringResult{ urls: urls, diff --git a/cmd/preflight.go b/cmd/preflight.go index 5928ef0b..3b5b1260 100644 --- a/cmd/preflight.go +++ b/cmd/preflight.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "net" "os" "strings" "time" @@ -233,7 +232,7 @@ func checkMemoryRecommended(parsed *intent.Intent) []checkResult { // so the check silently reported every port as available. Use net.Dial // instead — same behaviour as the diagnose port_listening checker, no // runtime dependency. -func checkPorts(_ *cobra.Command, _ target.Target, node *intent.NodeSpec) []checkResult { +func checkPorts(cmd *cobra.Command, tgt target.Target, node *intent.NodeSpec) []checkResult { ports := []struct { name string port int @@ -243,13 +242,12 @@ func checkPorts(_ *cobra.Command, _ target.Target, node *intent.NodeSpec) []chec {"p2p", node.Ports.P2P}, } - dialer := net.Dialer{Timeout: 1500 * time.Millisecond} var results []checkResult for _, p := range ports { if p.port == 0 { continue } - conn, err := dialer.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", p.port)) + conn, err := target.DialContext(cmd.Context(), tgt, "tcp", fmt.Sprintf("127.0.0.1:%d", p.port), 1500*time.Millisecond) if err == nil { _ = conn.Close() results = append(results, checkResult{ diff --git a/cmd/status.go b/cmd/status.go index 852bff26..dc4ed2e2 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -89,8 +89,8 @@ func runStatus(cmd *cobra.Command, args []string) error { "healthy": false, "logs": apply.LogsDescriptor(node), "api_endpoints": map[string]any{ - "http": fmt.Sprintf("http://127.0.0.1:%d", effectivePort(node.HTTPPort, 8090)), - "grpc": fmt.Sprintf("127.0.0.1:%d", effectivePort(node.GRPCPort, 50051)), + "http": fmt.Sprintf("http://%s:%d", target.EndpointHost(node.Target.Type, node.Target.Host), effectivePort(node.HTTPPort, 8090)), + "grpc": fmt.Sprintf("%s:%d", target.EndpointHost(node.Target.Type, node.Target.Host), effectivePort(node.GRPCPort, 50051)), }, } diff --git a/cmd/verify.go b/cmd/verify.go index 0550fa53..3d01408d 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -11,6 +11,7 @@ import ( "github.com/tronprotocol/tron-deployment/internal/intent" "github.com/tronprotocol/tron-deployment/internal/output" "github.com/tronprotocol/tron-deployment/internal/state" + "github.com/tronprotocol/tron-deployment/internal/target" ) var ( @@ -57,7 +58,7 @@ func runVerify(cmd *cobra.Command, args []string) error { for time.Now().Before(deadline) { attempt++ url := fmt.Sprintf("http://127.0.0.1:%d/wallet/getnowblock", httpPort) - out, err := tgt.Exec(cmd.Context(), "curl", "-s", "--max-time", "5", url) + out, err := target.Get(cmd.Context(), tgt, url, 5*time.Second) if err == nil { var block struct { BlockHeader struct { diff --git a/cmd/wait.go b/cmd/wait.go index 13bbb619..acb7c8a2 100644 --- a/cmd/wait.go +++ b/cmd/wait.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net" "os" "strings" "time" @@ -184,9 +183,8 @@ func expandHTTPURL(raw string, port int) string { return strings.ReplaceAll(raw, "{http}", fmt.Sprintf("http://127.0.0.1:%d", port)) } -func probeTCP(ctx context.Context, _ target.Target, port int) error { - d := net.Dialer{Timeout: 2 * time.Second} - conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port)) +func probeTCP(ctx context.Context, tgt target.Target, port int) error { + conn, err := target.DialContext(ctx, tgt, "tcp", fmt.Sprintf("127.0.0.1:%d", port), 2*time.Second) if err != nil { return err } @@ -212,8 +210,16 @@ func httpProbeURL(raw string) (string, error) { } func probeHTTP(ctx context.Context, nc *nodeContext, url string) error { - args := []string{"-fsS", "--max-time", "5", url} - out, err := nc.runtimeExec(ctx, "curl", args...) + var out []byte + var err error + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if nc.Node.Runtime == "jar" { + out, err = target.Get(probeCtx, nc.Target, url, 5*time.Second) + } else { + args := []string{"-fsS", "--max-time", "5", url} + out, err = nc.runtimeExec(ctx, "curl", args...) + } if err != nil { return fmt.Errorf("curl: %w", err) } diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6881e402..d8c72795 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -418,8 +418,8 @@ func Apply(ctx context.Context, opts Options) (*Result, error) { Network: opts.Intent.Network, IsPrivate: intent.IsPrivate(opts.Intent.Network), Endpoints: map[string]string{ - "http": fmt.Sprintf("http://127.0.0.1:%d", node.Ports.HTTP), - "grpc": fmt.Sprintf("127.0.0.1:%d", node.Ports.GRPC), + "http": fmt.Sprintf("http://%s:%d", target.EndpointHost(opts.Intent.Target.Type, opts.Intent.Target.Host), node.Ports.HTTP), + "grpc": fmt.Sprintf("%s:%d", target.EndpointHost(opts.Intent.Target.Type, opts.Intent.Target.Host), node.Ports.GRPC), }, DurationMs: deployedMs, Build: buildSummary, @@ -562,6 +562,7 @@ func noChangeResult(opts Options, buildSummary *BuildSummary, start time.Time) * } ports := opts.Intent.Nodes[0].Ports + host := target.EndpointHost(opts.Intent.Target.Type, opts.Intent.Target.Host) return &Result{ Name: opts.Intent.Name, Outcome: "no_change", @@ -572,24 +573,24 @@ func noChangeResult(opts Options, buildSummary *BuildSummary, start time.Time) * Network: opts.Intent.Network, IsPrivate: intent.IsPrivate(opts.Intent.Network), Endpoints: map[string]string{ - "http": fmt.Sprintf("http://127.0.0.1:%d", ports.HTTP), - "grpc": fmt.Sprintf("127.0.0.1:%d", ports.GRPC), + "http": fmt.Sprintf("http://%s:%d", host, ports.HTTP), + "grpc": fmt.Sprintf("%s:%d", host, ports.GRPC), }, DurationMs: time.Since(start).Milliseconds(), Build: buildSummary, - MonitoringEndpoints: monitoringEndpointsFromExisting(opts.Existing), + MonitoringEndpoints: monitoringEndpointsFromExisting(opts.Existing, host), } } // monitoringEndpointsFromExisting returns monitoring URLs from a managed // node's saved state, if monitoring was deployed for it. -func monitoringEndpointsFromExisting(existing *state.ManagedNode) map[string]string { +func monitoringEndpointsFromExisting(existing *state.ManagedNode, host string) map[string]string { if existing == nil || existing.Monitoring == nil || !existing.Monitoring.Enabled { return nil } return map[string]string{ - "prometheus_url": fmt.Sprintf("http://127.0.0.1:%d", existing.Monitoring.PrometheusPort), - "grafana_url": fmt.Sprintf("http://127.0.0.1:%d", existing.Monitoring.GrafanaPort), + "prometheus_url": fmt.Sprintf("http://%s:%d", host, existing.Monitoring.PrometheusPort), + "grafana_url": fmt.Sprintf("http://%s:%d", host, existing.Monitoring.GrafanaPort), } } diff --git a/internal/apply/observe_test.go b/internal/apply/observe_test.go index 2e1a8fc9..91f59871 100644 --- a/internal/apply/observe_test.go +++ b/internal/apply/observe_test.go @@ -2,6 +2,12 @@ package apply import ( "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" "strings" "testing" @@ -23,6 +29,43 @@ func newExecTarget(fn func(ctx context.Context, name string, args ...string) ([] return &execTarget{fakeTarget: &fakeTarget{}, exec: fn} } +// httpTarget is a fakeTarget whose DialContext redirects loopback probes to +// a test HTTP server. Jar-runtime LiveStatus probes go through the +// target-aware HTTP client (target.Get/Post over DialContext), so the fake +// must serve real HTTP rather than script Exec output. +type httpTarget struct { + *fakeTarget + server *httptest.Server +} + +func (h *httpTarget) DialContext(ctx context.Context, network, _ string) (net.Conn, error) { + addr := strings.TrimPrefix(h.server.URL, "http://") + return (&net.Dialer{}).DialContext(ctx, network, addr) +} + +// newHTTPStatusTarget starts an httptest server that answers the LiveStatus +// probe endpoints and returns a target wired to it plus the port LiveStatus +// should probe. handler may return ("", true) to fail a request. +func newHTTPStatusTarget(t *testing.T, respond func(path string, body []byte) (string, bool)) (*httpTarget, int) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqBody, _ := io.ReadAll(r.Body) + out, fail := respond(r.URL.Path, reqBody) + if fail { + w.WriteHeader(http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, out) + })) + t.Cleanup(srv.Close) + port, err := strconv.Atoi(strings.TrimPrefix(srv.URL, "http://127.0.0.1:")) + if err != nil { + t.Fatalf("parse httptest port: %v", err) + } + return &httpTarget{fakeTarget: &fakeTarget{}, server: srv}, port +} + func TestLogsDescriptor_Docker(t *testing.T) { node := &state.ManagedNode{Name: "fn0", Runtime: "docker"} got := LogsDescriptor(node) @@ -102,17 +145,16 @@ func TestContainerID_NilTarget(t *testing.T) { } func TestLiveStatus_SetsHealthy(t *testing.T) { - node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: 8090} - tgt := newExecTarget(func(_ context.Context, _ string, args ...string) ([]byte, error) { - url := args[len(args)-1] - switch { - case strings.Contains(url, "/wallet/getnowblock"): - return []byte(`{"block_header":{"raw_data":{"number":42,"timestamp":1}}}`), nil - case strings.Contains(url, "/wallet/listnodes"): - return []byte(`{"nodes":[{},{}]}`), nil + tgt, port := newHTTPStatusTarget(t, func(path string, _ []byte) (string, bool) { + switch path { + case "/wallet/getnowblock": + return `{"block_header":{"raw_data":{"number":42,"timestamp":1}}}`, false + case "/wallet/listnodes": + return `{"nodes":[{},{}]}`, false } - return nil, context.DeadlineExceeded + return "", true }) + node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: port} out := LiveStatus(context.Background(), tgt, node) if out["healthy"] != true { t.Errorf("healthy = %v, want true (RPC answered with a parseable block)", out["healthy"]) @@ -144,38 +186,47 @@ func TestIsHex64(t *testing.T) { func TestLiveStatus_GenesisBlockID(t *testing.T) { blockJSON := `{"block_header":{"raw_data":{"number":3,"timestamp":1}}}` - mk := func(genesisBody string, genesisErr bool) *execTarget { - return newExecTarget(func(_ context.Context, _ string, args ...string) ([]byte, error) { - url := args[len(args)-1] - switch { - case strings.Contains(url, "/wallet/getblockbynum"): + mk := func(genesisBody string, genesisErr bool) (*httpTarget, int) { + return newHTTPStatusTarget(t, func(path string, _ []byte) (string, bool) { + switch path { + case "/wallet/getblockbynum": if genesisErr { - return nil, context.DeadlineExceeded + return "", true } - return []byte(genesisBody), nil - case strings.Contains(url, "/wallet/getnowblock"): - return []byte(blockJSON), nil + return genesisBody, false + case "/wallet/getnowblock": + return blockJSON, false } - return nil, context.DeadlineExceeded + return "", true }) } - node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: 8090} // Valid blockID → surfaced. - out := LiveStatus(context.Background(), mk(`{"blockID":"`+genesisBlockID+`","block_header":{}}`, false), node) + tgt, port := newHTTPStatusTarget(t, func(path string, _ []byte) (string, bool) { + if path == "/wallet/getblockbynum" { + return `{"blockID":"` + genesisBlockID + `","block_header":{}}`, false + } + if path == "/wallet/getnowblock" { + return blockJSON, false + } + return "", true + }) + out := LiveStatus(context.Background(), tgt, &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: port}) if out["genesis_block_id"] != genesisBlockID { t.Errorf("genesis_block_id = %v, want %s", out["genesis_block_id"], genesisBlockID) } // Malformed blockID (not 64-hex) → absent. - out = LiveStatus(context.Background(), mk(`{"blockID":"nope"}`, false), node) + tgt, port = mk(`{"blockID":"nope"}`, false) + out = LiveStatus(context.Background(), tgt, &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: port}) if _, ok := out["genesis_block_id"]; ok { t.Errorf("genesis_block_id must be absent for a malformed blockID; got %v", out["genesis_block_id"]) } // Probe failed (node down) → absent, but the primary healthy signal // (from getnowblock) is unaffected — proves it doesn't starve them. - out = LiveStatus(context.Background(), mk("", true), node) + tgt, port = mk("", true) + out = LiveStatus(context.Background(), tgt, &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: port}) if _, ok := out["genesis_block_id"]; ok { t.Errorf("genesis_block_id must be absent when the probe fails; got %v", out["genesis_block_id"]) } @@ -185,11 +236,10 @@ func TestLiveStatus_GenesisBlockID(t *testing.T) { } func TestLiveStatus_NoHealthyOnProbeFailure(t *testing.T) { - node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: 8090} - tgt := newExecTarget(func(_ context.Context, _ string, _ ...string) ([]byte, error) { - return nil, context.DeadlineExceeded // node down: every probe fails - }) - out := LiveStatus(context.Background(), tgt, node) + // No listener behind the target: every dial fails, so every probe errs + // (a node that is down behaves the same over the tunnel). + node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: 1} + out := LiveStatus(context.Background(), &fakeTarget{}, node) if _, ok := out["healthy"]; ok { t.Errorf("healthy must be absent when the probe fails (caller seeds false); got %v", out["healthy"]) } @@ -201,13 +251,13 @@ func TestLiveStatus_NoHealthyOnProbeFailure(t *testing.T) { // not fabricate a block_height of 0. func TestLiveStatus_NoHealthyOnEmptyOrErrorBody(t *testing.T) { for _, body := range []string{`{}`, `{"Error":"some node error"}`, `{"block_header":{"raw_data":{"number":0,"timestamp":0}}}`} { - node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: 8090} - tgt := newExecTarget(func(_ context.Context, _ string, args ...string) ([]byte, error) { - if strings.Contains(args[len(args)-1], "/wallet/getnowblock") { - return []byte(body), nil + tgt, port := newHTTPStatusTarget(t, func(path string, _ []byte) (string, bool) { + if path == "/wallet/getnowblock" { + return body, false } - return nil, context.DeadlineExceeded + return "", true }) + node := &state.ManagedNode{Name: "fn0", Runtime: "jar", HTTPPort: port} out := LiveStatus(context.Background(), tgt, node) if _, ok := out["healthy"]; ok { t.Errorf("body %q: healthy must be absent (no real block); got %v", body, out["healthy"]) diff --git a/internal/apply/probe.go b/internal/apply/probe.go index db9b817b..eeccb49b 100644 --- a/internal/apply/probe.go +++ b/internal/apply/probe.go @@ -35,19 +35,25 @@ func LiveStatus(ctx context.Context, tgt target.Target, node *state.ManagedNode) port = 8090 } - // probe issues a curl against the node's HTTP API. body=="" is a GET - // (TRON endpoints that take no params, e.g. getnowblock); a non-empty - // body is POSTed as JSON (e.g. getblockbynum needs {"num":N}). + // probe issues a request against the node's HTTP API. body=="" is a + // GET (TRON endpoints that take no params, e.g. getnowblock); a + // non-empty body is POSTed as JSON (e.g. getblockbynum needs + // {"num":N}). Jar nodes go through the target-aware HTTP client + // (SSH-tunnelled for remote targets); docker nodes curl inside the + // container via `docker exec`. probe := func(path, body string) ([]byte, error) { url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path) + if node.Runtime == "jar" { + if body == "" { + return target.Get(ctx, tgt, url, 2*time.Second) + } + return target.Post(ctx, tgt, url, []byte(body), 2*time.Second) + } args := []string{"-fsS", "--max-time", "2"} if body != "" { args = append(args, "-X", "POST", "-H", "Content-Type: application/json", "-d", body) } args = append(args, url) - if node.Runtime == "jar" { - return tgt.Exec(ctx, "curl", args...) - } return tgt.Exec(ctx, "docker", append([]string{"exec", node.Name, "curl"}, args...)...) } diff --git a/internal/diagnosis/peers.go b/internal/diagnosis/peers.go index 8f6bd6fb..28768448 100644 --- a/internal/diagnosis/peers.go +++ b/internal/diagnosis/peers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/tronprotocol/tron-deployment/internal/target" ) @@ -19,7 +20,7 @@ func (c *PeersChecker) Run(ctx context.Context, tgt target.Target, opts CheckOpt } url := fmt.Sprintf("http://127.0.0.1:%d/wallet/listnodes", opts.HTTPPort) - out, err := tgt.Exec(ctx, "curl", "-s", "--max-time", "5", url) + out, err := target.Get(ctx, tgt, url, 5*time.Second) if err != nil { return CheckResult{ Name: c.Name(), diff --git a/internal/diagnosis/ports.go b/internal/diagnosis/ports.go index 9e936cb5..418ea54c 100644 --- a/internal/diagnosis/ports.go +++ b/internal/diagnosis/ports.go @@ -3,7 +3,6 @@ package diagnosis import ( "context" "fmt" - "net" "time" "github.com/tronprotocol/tron-deployment/internal/target" @@ -11,30 +10,30 @@ import ( // PortsChecker verifies expected ports are accepting TCP connections. // -// We probe via net.Dial against 127.0.0.1 from the host where trond is -// running, instead of running `ss -tlnp` inside the target. This works -// the same on Linux and macOS (the previous `ss` invocation silently -// returned empty on Darwin, marking every port as "not listening" even -// when java-tron was healthy and serving traffic). For docker-runtime -// nodes, the host-side mapped port is exactly what test harnesses care -// about reaching, so this is also the right thing semantically. +// We dial 127.0.0.1 through the target (SSH direct-tcpip when the target +// is remote, a plain host dial for local), instead of running `ss -tlnp` +// inside the target. This works the same on Linux and macOS (the previous +// `ss` invocation silently returned empty on Darwin, marking every port +// as "not listening" even when java-tron was healthy and serving traffic). +// For docker-runtime nodes, the host-side mapped port is exactly what test +// harnesses care about reaching, so this is also the right thing +// semantically. type PortsChecker struct{} func (c *PortsChecker) Name() string { return "port_listening" } -func (c *PortsChecker) Run(ctx context.Context, _ target.Target, opts CheckOpts) CheckResult { +func (c *PortsChecker) Run(ctx context.Context, tgt target.Target, opts CheckOpts) CheckResult { ports := []int{opts.HTTPPort, opts.GRPCPort} if opts.HTTPPort == 0 && opts.GRPCPort == 0 { ports = []int{8090, 50051} } - dialer := net.Dialer{Timeout: 1500 * time.Millisecond} var missing []int for _, port := range ports { if port == 0 { continue } - conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port)) + conn, err := target.DialContext(ctx, tgt, "tcp", fmt.Sprintf("127.0.0.1:%d", port), 1500*time.Millisecond) if err != nil { missing = append(missing, port) continue diff --git a/internal/diagnosis/sync.go b/internal/diagnosis/sync.go index 7cc73183..cc96f066 100644 --- a/internal/diagnosis/sync.go +++ b/internal/diagnosis/sync.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/tronprotocol/tron-deployment/internal/target" ) @@ -20,7 +21,7 @@ func (c *SyncChecker) Run(ctx context.Context, tgt target.Target, opts CheckOpts } url := fmt.Sprintf("http://127.0.0.1:%d/wallet/getnowblock", opts.HTTPPort) - out, err := tgt.Exec(ctx, "curl", "-s", "--max-time", "5", url) + out, err := target.Get(ctx, tgt, url, 5*time.Second) if err != nil { return CheckResult{ Name: c.Name(), diff --git a/internal/diagnosis/version.go b/internal/diagnosis/version.go index 935ede7d..d874d988 100644 --- a/internal/diagnosis/version.go +++ b/internal/diagnosis/version.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/tronprotocol/tron-deployment/internal/target" ) @@ -21,7 +22,7 @@ func (c *VersionChecker) Run(ctx context.Context, tgt target.Target, opts CheckO } url := fmt.Sprintf("http://127.0.0.1:%d/wallet/getnodeinfo", opts.HTTPPort) - out, err := tgt.Exec(ctx, "curl", "-s", "--max-time", "5", url) + out, err := target.Get(ctx, tgt, url, 5*time.Second) if err != nil { return CheckResult{ Name: c.Name(), diff --git a/internal/mcp/helpers.go b/internal/mcp/helpers.go index 27822675..be1b9655 100644 --- a/internal/mcp/helpers.go +++ b/internal/mcp/helpers.go @@ -29,14 +29,16 @@ func notFoundWithSuggestions(resource, name string, suggestions ...string) *outp WithSuggestions(suggestions...) } -// httpURL formats a port into the http://127.0.0.1:

URL we surface +// httpURL formats host+port into the http://:

URL we surface // to agents. Agents can re-use this in their own follow-up probes -// (e.g. `wait --http `). -func httpURL(port int) string { - return "http://127.0.0.1:" + strconv.Itoa(port) +// (e.g. `wait --http `). For ssh targets the host is the recorded +// remote host; internal probes that dial through the target pass +// 127.0.0.1 (the tunnel lands on the remote loopback). +func httpURL(host string, port int) string { + return "http://" + host + ":" + strconv.Itoa(port) } // grpcAddr formats the host:port grpc endpoint. -func grpcAddr(port int) string { - return "127.0.0.1:" + strconv.Itoa(port) +func grpcAddr(host string, port int) string { + return host + ":" + strconv.Itoa(port) } diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 0f7b72a8..8c10e624 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -11,6 +11,7 @@ import ( "github.com/tronprotocol/tron-deployment/internal/paths" "github.com/tronprotocol/tron-deployment/internal/state" + "github.com/tronprotocol/tron-deployment/internal/target" ) // registerResources wires read-only data sources as MCP resources. @@ -140,13 +141,14 @@ func readNodeEndpointsResource(_ context.Context, req *mcp.ReadResourceRequest) if err != nil { return nil, err } + host := target.EndpointHost(node.Target.Type, node.Target.Host) endpoints := map[string]any{ "name": node.Name, "runtime": node.Runtime, "target": node.Target, "endpoints": map[string]string{ - "http": fmt.Sprintf("http://127.0.0.1:%d", node.HTTPPort), - "grpc": fmt.Sprintf("127.0.0.1:%d", node.GRPCPort), + "http": fmt.Sprintf("http://%s:%d", host, node.HTTPPort), + "grpc": fmt.Sprintf("%s:%d", host, node.GRPCPort), }, "version": node.Version, "labels": node.Labels, diff --git a/internal/mcp/tools_diagnostic.go b/internal/mcp/tools_diagnostic.go index 2ee5946a..d94e32fe 100644 --- a/internal/mcp/tools_diagnostic.go +++ b/internal/mcp/tools_diagnostic.go @@ -14,6 +14,7 @@ import ( "github.com/tronprotocol/tron-deployment/internal/diagnosis" "github.com/tronprotocol/tron-deployment/internal/paths" "github.com/tronprotocol/tron-deployment/internal/state" + "github.com/tronprotocol/tron-deployment/internal/target" ) // registerDiagnosticTools wires up read-only triage tools. Pure-data @@ -169,7 +170,21 @@ func healthTool(ctx context.Context, _ *mcp.CallToolRequest, args nodeArg) (*mcp if port == 0 { port = 8090 } - url := httpURL(port) + "/wallet/getnowblock" + // The probe URL stays on loopback: target.HTTPClient dials through the + // SSH tunnel, which lands on the remote host's loopback. + url := httpURL("127.0.0.1", port) + "/wallet/getnowblock" + endpoint := httpURL(target.EndpointHost(node.Target.Type, node.Target.Host), port) + "/wallet/getnowblock" + + // Probe through the node's target (SSH-tunnelled for remote nodes), + // not via http.DefaultClient against this host's loopback — that + // bypassed SSH entirely and always failed for remote rigs. + tgt, err := mcpResolveTargetFromNode(node) + if err != nil { + return errResult(err) + } + if c, ok := any(tgt).(interface{ Close() error }); ok { + defer c.Close() + } probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() @@ -178,11 +193,11 @@ func healthTool(ctx context.Context, _ *mcp.CallToolRequest, args nodeArg) (*mcp return errResult(err) } start := time.Now() - resp, err := http.DefaultClient.Do(req) + resp, err := target.HTTPClient(tgt, 5*time.Second).Do(req) if err != nil { return jsonResult(map[string]any{ "name": args.Name, "healthy": false, - "endpoint": url, "error": err.Error(), + "endpoint": endpoint, "error": err.Error(), }) } defer resp.Body.Close() @@ -190,7 +205,7 @@ func healthTool(ctx context.Context, _ *mcp.CallToolRequest, args nodeArg) (*mcp return jsonResult(map[string]any{ "name": args.Name, "healthy": healthy, - "endpoint": url, + "endpoint": endpoint, "latency_ms": time.Since(start).Milliseconds(), "status": resp.StatusCode, }) diff --git a/internal/mcp/tools_inspection.go b/internal/mcp/tools_inspection.go index 47d6f35e..3e29181e 100644 --- a/internal/mcp/tools_inspection.go +++ b/internal/mcp/tools_inspection.go @@ -205,10 +205,10 @@ func inspectAllNodes(ctx context.Context, _ *mcp.CallToolRequest, _ emptyArgs) ( // extraction. eps := map[string]string{} if n.HTTPPort != 0 { - eps["http"] = httpURL(n.HTTPPort) + eps["http"] = httpURL(target.EndpointHost(n.Target.Type, n.Target.Host), n.HTTPPort) } if n.GRPCPort != 0 { - eps["grpc"] = grpcAddr(n.GRPCPort) + eps["grpc"] = grpcAddr(target.EndpointHost(n.Target.Type, n.Target.Host), n.GRPCPort) } if len(eps) > 0 { row["endpoints"] = eps diff --git a/internal/target/local.go b/internal/target/local.go index af15f52f..91566d7c 100644 --- a/internal/target/local.go +++ b/internal/target/local.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "net" "os" "os/exec" "path/filepath" @@ -17,6 +18,10 @@ import ( // LocalTarget executes commands and file operations on the local machine. type LocalTarget struct{} +func (t *LocalTarget) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) +} + // NewLocalTarget creates a new LocalTarget. func NewLocalTarget() *LocalTarget { return &LocalTarget{} diff --git a/internal/target/ssh.go b/internal/target/ssh.go index c9f0db3a..fe2f68be 100644 --- a/internal/target/ssh.go +++ b/internal/target/ssh.go @@ -35,6 +35,23 @@ type SSHTarget struct { client *ssh.Client } +func (t *SSHTarget) DialContext(_ context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("ssh direct dial requires host:port address: %w", err) + } + if host != "localhost" { + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return nil, fmt.Errorf("ssh direct dial address %q is not loopback", addr) + } + } + if t.client == nil { + return nil, fmt.Errorf("ssh not connected") + } + return t.client.Dial(network, addr) +} + // NewSSHTarget creates a new SSHTarget. Call Connect() before use. func NewSSHTarget(host string, port int, user, identityFile string) *SSHTarget { if port == 0 { diff --git a/internal/target/target.go b/internal/target/target.go index a87a66d7..7782d912 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -1,10 +1,112 @@ package target import ( + "bytes" "context" + "fmt" + "io" + "net" + "net/http" "os" + "time" ) +// Dialer is optionally implemented by targets that can reach their own +// loopback through an existing connection (for example, SSH direct-tcpip). +// DialContext callers must pass an address on the target's own loopback. +type Dialer interface { + DialContext(ctx context.Context, network, addr string) (net.Conn, error) +} + +// HTTPClient returns an HTTP client that reaches node-local addresses through +// the target when it supports dialing. Local and test targets use the normal +// host transport. +func HTTPClient(t Target, timeout time.Duration) *http.Client { + client := &http.Client{Timeout: timeout} + if d, ok := t.(Dialer); ok { + client.Transport = &http.Transport{DialContext: d.DialContext} + } + return client +} + +// EndpointHost returns the host used when reporting a target endpoint. +func EndpointHost(targetType, host string) string { + if targetType == "ssh" && host != "" { + return host + } + return "127.0.0.1" +} + +// DialContext dials through the target when it implements Dialer (reaching +// the target's own loopback, e.g. over an SSH direct-tcpip channel), and +// falls back to a plain net.Dialer otherwise. +func DialContext(ctx context.Context, t Target, network, addr string, timeout time.Duration) (net.Conn, error) { + if d, ok := t.(Dialer); ok { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + type dialResult struct { + conn net.Conn + err error + } + ch := make(chan dialResult, 1) + go func() { + conn, err := d.DialContext(ctx, network, addr) + ch <- dialResult{conn, err} + }() + select { + case r := <-ch: + return r.conn, r.err + case <-ctx.Done(): + // A dial that succeeds after timeout may leave an unowned conn; + // the SSH/session lifetime will reclaim it. This is an accepted + // tradeoff for making the timeout preemptible. + return nil, ctx.Err() + } + } + return (&net.Dialer{Timeout: timeout}).DialContext(ctx, network, addr) +} + +// Get issues an HTTP GET through the target-aware client and returns the +// response body. Non-2xx statuses are an error carrying a body snippet. +func Get(ctx context.Context, t Target, url string, timeout time.Duration) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + return do(HTTPClient(t, timeout), req) +} + +// Post issues an HTTP POST with a JSON body through the target-aware client +// and returns the response body. Non-2xx statuses are an error. +func Post(ctx context.Context, t Target, url string, body []byte, timeout time.Duration) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return do(HTTPClient(t, timeout), req) +} + +func do(client *http.Client, req *http.Request) ([]byte, error) { + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := out + if len(snippet) > 200 { + snippet = snippet[:200] + } + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, snippet) + } + return out, nil +} + // Target abstracts command execution and file operations on a deployment target. type Target interface { // Exec runs a command on the target and returns combined output. diff --git a/internal/target/target_test.go b/internal/target/target_test.go new file mode 100644 index 00000000..a8003c37 --- /dev/null +++ b/internal/target/target_test.go @@ -0,0 +1,204 @@ +package target + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// LocalTargetNoDial satisfies Target WITHOUT the optional Dialer interface, +// to prove the fallback paths (plain host transport / net.Dialer) still +// work for targets that cannot tunnel. It must NOT embed LocalTarget: the +// embedded *LocalTarget method set would promote DialContext onto it and +// silently re-implement Dialer. Methods are never exercised by the tests +// below (they only dial), so they fail loudly if that ever changes. +type LocalTargetNoDial struct{} + +type blockingDialer struct{} + +func (blockingDialer) DialContext(ctx context.Context, _, _ string) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +type blockingTarget struct{ LocalTargetNoDial } + +func (blockingTarget) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + return blockingDialer{}.DialContext(ctx, network, addr) +} + +var _ Target = (*LocalTargetNoDial)(nil) + +func (l *LocalTargetNoDial) Exec(context.Context, string, ...string) ([]byte, error) { + return nil, fmt.Errorf("LocalTargetNoDial: Exec not implemented") +} +func (l *LocalTargetNoDial) Upload(context.Context, string, string) error { + return fmt.Errorf("LocalTargetNoDial: Upload not implemented") +} +func (l *LocalTargetNoDial) Download(context.Context, string, string) error { + return fmt.Errorf("LocalTargetNoDial: Download not implemented") +} +func (l *LocalTargetNoDial) ReadFile(context.Context, string) ([]byte, error) { + return nil, fmt.Errorf("LocalTargetNoDial: ReadFile not implemented") +} +func (l *LocalTargetNoDial) WriteFile(context.Context, string, []byte, os.FileMode) error { + return fmt.Errorf("LocalTargetNoDial: WriteFile not implemented") +} +func (l *LocalTargetNoDial) DiskFree(context.Context, string) (uint64, error) { + return 0, fmt.Errorf("LocalTargetNoDial: DiskFree not implemented") +} +func (l *LocalTargetNoDial) MemTotal(context.Context) (uint64, error) { + return 0, fmt.Errorf("LocalTargetNoDial: MemTotal not implemented") +} +func (l *LocalTargetNoDial) PutFile(context.Context, string, string) error { + return fmt.Errorf("LocalTargetNoDial: PutFile not implemented") +} +func (l *LocalTargetNoDial) Sha256IfExists(context.Context, string) (string, error) { + return "", fmt.Errorf("LocalTargetNoDial: Sha256IfExists not implemented") +} +func (l *LocalTargetNoDial) CommandExists(context.Context, string) bool { return false } +func (l *LocalTargetNoDial) String() string { return "local-no-dial" } + +// TestEndpointHost pins the ssh-vs-local reporting rule: ssh targets with a +// recorded host report that host; everything else reports loopback. +func TestEndpointHost(t *testing.T) { + cases := []struct { + targetType, host, want string + }{ + {"ssh", "10.0.0.5", "10.0.0.5"}, + {"ssh", "", "127.0.0.1"}, // recorded host missing → fail-safe loopback + {"local", "", "127.0.0.1"}, + {"local", "ignored", "127.0.0.1"}, + {"", "", "127.0.0.1"}, + } + for _, tc := range cases { + if got := EndpointHost(tc.targetType, tc.host); got != tc.want { + t.Errorf("EndpointHost(%q, %q) = %q, want %q", tc.targetType, tc.host, got, tc.want) + } + } +} + +// TestLocalTargetDialContext proves the local DialContext reaches a real +// listener — this is what makes target.Get/Post work unchanged for local +// targets after the ssh-tunnel migration. +func TestLocalTargetDialContext(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + conn, err := NewLocalTarget().DialContext(context.Background(), "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("DialContext: %v", err) + } + conn.Close() +} + +// TestHTTPClientFallsBackForNonDialer: a Target that does not implement +// Dialer must get a working default-transport client (plain host dial). +func TestHTTPClientFallsBackForNonDialer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + defer srv.Close() + + client := HTTPClient(&LocalTargetNoDial{}, 2*time.Second) + if _, ok := client.Transport.(*http.Transport); ok { + t.Fatalf("non-Dialer target must keep the default transport, got a custom one") + } + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("GET via fallback client: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +// TestDialContextFallback: the free DialContext helper falls back to a plain +// net.Dialer when the target does not implement Dialer. +func TestDialContextFallback(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + conn, err := DialContext(context.Background(), &LocalTargetNoDial{}, "tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + t.Fatalf("DialContext fallback: %v", err) + } + conn.Close() +} + +func TestSSHTargetDialContextNilClient(t *testing.T) { + _, err := NewSSHTarget("host", 22, "user", "").DialContext(context.Background(), "tcp", "127.0.0.1:8090") + if err == nil || !strings.Contains(err.Error(), "ssh not connected") { + t.Fatalf("DialContext nil client error = %v", err) + } +} + +func TestSSHTargetDialContextRejectsNonLoopback(t *testing.T) { + _, err := NewSSHTarget("host", 22, "user", "").DialContext(context.Background(), "tcp", "10.0.0.5:8090") + if err == nil || !strings.Contains(err.Error(), "not loopback") { + t.Fatalf("DialContext non-loopback error = %v", err) + } +} + +func TestDialContextDialerHonorsTimeout(t *testing.T) { + start := time.Now() + _, err := DialContext(context.Background(), &blockingTarget{}, "tcp", "127.0.0.1:8090", 50*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("DialContext timeout error = %v", err) + } + if elapsed := time.Since(start); elapsed > 500*time.Millisecond { + t.Fatalf("DialContext took %s, want near 50ms", elapsed) + } +} + +// TestGetNon2xxIsError pins the status-code contract: a non-2xx response is +// an error carrying a body snippet (curl -fsS semantics the probes replaced). +func TestGetNon2xxIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "boom") + })) + defer srv.Close() + + _, err := Get(context.Background(), NewLocalTarget(), srv.URL, 2*time.Second) + if err == nil || !strings.Contains(err.Error(), "http 500") { + t.Fatalf("Get on 500 = %v, want http 500 error", err) + } +} + +// TestPostSendsJSONBody verifies Post sets the JSON content type and ships +// the body through (getblockbynum-style probes depend on it). +func TestPostSendsJSONBody(t *testing.T) { + var gotCT, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCT = r.Header.Get("Content-Type") + buf := make([]byte, 64) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{}`) + })) + defer srv.Close() + + if _, err := Post(context.Background(), NewLocalTarget(), srv.URL, []byte(`{"num":0}`), 2*time.Second); err != nil { + t.Fatalf("Post: %v", err) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotCT) + } + if gotBody != `{"num":0}` { + t.Errorf("body = %q, want %q", gotBody, `{"num":0}`) + } +} From c60ccfd45694eb57dd83125e319ff1fbf8eda036 Mon Sep 17 00:00:00 2001 From: warku123 Date: Wed, 19 Aug 2026 12:11:47 +0800 Subject: [PATCH 4/5] chore(ci): pause tag-triggered releases (manual dispatch only) --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2afadb3e..46d99e05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ name: Release on: - push: - tags: ["v*"] + # Tag-triggered releases are temporarily paused (TX-167). The pipeline + # itself is kept intact; run it manually from the Actions tab or via + # `gh workflow run release.yml` when releases resume. + workflow_dispatch: permissions: contents: write From 5abf817d9250ad3cc6078942c26647fa3b3cd6aa Mon Sep 17 00:00:00 2001 From: warku123 Date: Wed, 19 Aug 2026 17:19:46 +0800 Subject: [PATCH 5/5] test(render): remove ineffectual assignment --- internal/render/hocon_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/render/hocon_test.go b/internal/render/hocon_test.go index 7a86b232..4044e661 100644 --- a/internal/render/hocon_test.go +++ b/internal/render/hocon_test.go @@ -75,13 +75,13 @@ func TestRenderHOCON_HTTPPortCollisionExplicitSolidity(t *testing.T) { // config_overrides is the explicit escape hatch for operators who // intentionally need the equal value. node := &intent.NodeSpec{Type: "fullnode", Ports: intent.PortMapping{HTTP: 8091, SolidityHTTP: 8091}} - out, err := RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) + _, err := RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) if err == nil || !strings.Contains(err.Error(), "solidityPort") { t.Fatalf("expected solidity collision error, got %v", err) } node.ConfigOverrides = map[string]any{"node.http.solidityPort": 8091} - out, err = RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) + out, err := RenderHOCON("", &intent.Intent{Name: "ports", Network: "mainnet"}, node) if err != nil { t.Fatalf("render with override: %v", err) }