diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 40043acf6..d7cb78a3a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -626,6 +626,13 @@ to a route-inferred interface IP. Route-aware IP detection remains the fallback only when hostname resolution is unusable, so multi-NIC and internal-CA deployments preserve canonical hostname continuity without losing an IP escape hatch for non-DNS installs. +That same Proxmox registration boundary must now also let Pulse choose from the +agent's ordered candidate host list instead of blindly persisting the agent's +first preference. Unified Agent setup must send canonical `candidateHosts` +alongside the preferred `host`, and `/api/auto-register` must store the first +candidate that Pulse can actually reach for fingerprint capture from its own +network view so mixed-DNS and split-network installs do not register a host the +server itself cannot use afterward. That same canonical behavior also includes one auth transport for Proxmox completion: runtime-side Unified Agent and script callers must send `/api/auto-register` authentication through a one-time setup token in the request-body diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 961d126cc..bce12e500 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -112,6 +112,7 @@ Own canonical runtime payload shapes between backend and frontend. 34. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary. 35. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary. 36. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. + That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint. 37. `internal/api/enterprise_extension_rbac_admin.go` shared with `organization-settings`: RBAC admin extension endpoints are both an organization settings control surface and a canonical API payload contract boundary. 38. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership. 39. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership. @@ -1366,6 +1367,10 @@ preferred candidate, not an untouchable answer. The backend must normalize the candidate list, ignore invalid alternates, and persist the first candidate it can actually reach for TLS fingerprint capture from Pulse's own network view so registration payloads do not lock in an endpoint the server cannot later poll. +That same response contract must echo the stored reachable candidate back in +the canonical `host` field, not the caller's rejected first preference, so +runtime-side Unified Agent confirmation and later setup/install surfaces stay +aligned on the actual persisted polling endpoint. The unified-agent install endpoints now also carry an exact-release fallback contract: when `/install.sh` or `/install.ps1` cannot be served locally, the backend must proxy the install script asset from the exact GitHub release that diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 4630e4fde..5e4b05f5f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -78,6 +78,11 @@ querying, and the operator-facing storage health presentation layer. 12. Preserve the canonical auto-register node-type boundary in those same shared helpers so only supported `pve` and `pbs` registrations can complete, and unsupported runtime labels cannot bleed fake node identities into adjacent transport or recovery-adjacent state. 13. Preserve the canonical auto-register token-identity boundary in those same shared helpers so only Pulse-managed `pulse-monitor@{pve|pbs}!pulse-` token IDs matching the requested node type can complete, and arbitrary, cross-type, or non-Pulse-managed token identities cannot bleed into adjacent transport or recovery-adjacent state. 14. Preserve canonical /api/auto-register DHCP continuity in those shared helpers so a PVE or PBS node that reruns registration from a new IP with the same canonical node name and deterministic Pulse-managed token identity updates in place instead of duplicating the inventory record. + That same shared helper boundary now also owns runtime-side Proxmox + `candidateHosts` selection from Pulse's network view: storage and + recovery-adjacent transport flows may not bypass server-side reachable-host + selection or persist the caller's first preferred host when the canonical + auto-register helper has already chosen a different reachable endpoint. 15. Preserve the governed root-or-sudo Unix wrapper in shared backend install-command helpers so storage- and recovery-adjacent transport surfaces do not inherit a stale raw `| bash -s --` install payload shape from the canonical agent-install-command API and hosted Proxmox install responses. 16. Preserve optional-auth tokenless behavior in those same shared backend install-command helpers so adjacent transport surfaces do not implicitly persist API tokens and flip auth-configured state when an operator only requested a Proxmox install command on a token-optional Pulse instance. 17. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so storage- and recovery-adjacent transport surfaces do not inherit browser-authored wildcard token bundles when they depend on the canonical security helper layer. diff --git a/internal/api/config_handlers_auto_register_test.go b/internal/api/config_handlers_auto_register_test.go index 40de20246..a7ea48bf1 100644 --- a/internal/api/config_handlers_auto_register_test.go +++ b/internal/api/config_handlers_auto_register_test.go @@ -13,6 +13,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" ) func newTestConfigHandlers(t *testing.T, cfg *config.Config) *ConfigHandlers { @@ -648,6 +649,87 @@ func TestHandleAutoRegisterPreservesExplicitAgentSource(t *testing.T) { } } +func TestHandleAutoRegisterSelectsReachableFallbackCandidateHost(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", tempDir) + + pveServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer pveServer.Close() + + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + } + + handler := newTestConfigHandlers(t, cfg) + + const setupToken = "TEMP-TOKEN" + tokenHash := internalauth.HashAPIToken(setupToken) + handler.codeMutex.Lock() + handler.setupTokens[tokenHash] = &SetupTokenRecord{ + ExpiresAt: time.Now().Add(5 * time.Minute), + NodeType: "pve", + } + handler.codeMutex.Unlock() + + reqBody := AutoRegisterRequest{ + Type: "pve", + Host: "https://127.0.0.1:1", + CandidateHosts: []string{pveServer.URL}, + TokenID: "pulse-monitor@pve!pulse-fallback-node", + TokenValue: "secret-token", + ServerName: "pve-node-1", + AuthToken: setupToken, + Source: "agent", + } + body, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("failed to marshal request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/auto-register", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleAutoRegister(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("registration failed: status=%d, body=%s", rec.Code, rec.Body.String()) + } + + if len(cfg.PVEInstances) != 1 { + t.Fatalf("expected 1 PVE instance, got %d", len(cfg.PVEInstances)) + } + + expectedHost, err := normalizeNodeHost(pveServer.URL, "pve") + if err != nil { + t.Fatalf("failed to normalize expected host: %v", err) + } + if cfg.PVEInstances[0].Host != expectedHost { + t.Fatalf("selected host = %q, want reachable fallback %q", cfg.PVEInstances[0].Host, expectedHost) + } + + expectedFP, err := tlsutil.FetchFingerprint(pveServer.URL) + if err != nil { + t.Fatalf("failed to fetch expected fingerprint: %v", err) + } + if cfg.PVEInstances[0].Fingerprint != expectedFP { + t.Fatalf("fingerprint = %q, want %q", cfg.PVEInstances[0].Fingerprint, expectedFP) + } + if !cfg.PVEInstances[0].VerifySSL { + t.Fatal("expected verifySSL to be enabled when fallback fingerprint capture succeeds") + } + + var response struct { + Host string `json:"host"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if response.Host != expectedHost { + t.Fatalf("response host = %q, want %q", response.Host, expectedHost) + } +} + func TestHandleAutoRegister_BlocksNewCountedSystemAtLimit(t *testing.T) { stubAutoRegisterNetworkDeps(t) diff --git a/internal/api/config_setup_handlers.go b/internal/api/config_setup_handlers.go index 47e8f0b22..c09be1a31 100644 --- a/internal/api/config_setup_handlers.go +++ b/internal/api/config_setup_handlers.go @@ -252,13 +252,14 @@ func (h *ConfigHandlers) handleSetupScriptURL(w http.ResponseWriter, r *http.Req // AutoRegisterRequest represents a request from the setup script or agent to auto-register a node type AutoRegisterRequest struct { - Type string `json:"type"` // "pve" or "pbs" - Host string `json:"host"` // The host URL - TokenID string `json:"tokenId"` // Full token ID like pulse-monitor@pve!pulse-token - TokenValue string `json:"tokenValue,omitempty"` // The token value for the node - ServerName string `json:"serverName,omitempty"` // Hostname or IP - AuthToken string `json:"authToken,omitempty"` // One-time setup token from setup/install flows - Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered + Type string `json:"type"` // "pve" or "pbs" + Host string `json:"host"` // The preferred host URL + CandidateHosts []string `json:"candidateHosts,omitempty"` // Alternate host URLs the server can try from its own network view + TokenID string `json:"tokenId"` // Full token ID like pulse-monitor@pve!pulse-token + TokenValue string `json:"tokenValue,omitempty"` // The token value for the node + ServerName string `json:"serverName,omitempty"` // Hostname or IP + AuthToken string `json:"authToken,omitempty"` // One-time setup token from setup/install flows + Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered } // AutoRegisterResponse is the canonical success shape for /api/auto-register. @@ -307,6 +308,73 @@ func isCanonicalAutoRegisterSource(source string) bool { } } +func normalizeAutoRegisterHostCandidates(nodeType, primary string, alternates []string) ([]string, error) { + candidates := make([]string, 0, len(alternates)+1) + seen := make(map[string]struct{}, len(alternates)+1) + addCandidate := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + normalized, err := normalizeNodeHost(raw, nodeType) + if err != nil { + return err + } + if _, exists := seen[normalized]; exists { + return nil + } + seen[normalized] = struct{}{} + candidates = append(candidates, normalized) + return nil + } + + if err := addCandidate(primary); err != nil { + return nil, err + } + for _, candidate := range alternates { + if err := addCandidate(candidate); err != nil { + log.Debug(). + Str("type", nodeType). + Str("candidate", candidate). + Err(err). + Msg("Ignoring invalid auto-register host candidate") + } + } + + if len(candidates) == 0 { + return nil, fmt.Errorf("host is required") + } + return candidates, nil +} + +func selectAutoRegisterHost(nodeType, primary string, alternates []string) (string, string, []string, error) { + candidates, err := normalizeAutoRegisterHostCandidates(nodeType, primary, alternates) + if err != nil { + return "", "", nil, err + } + + for idx, candidate := range candidates { + fingerprint, err := fetchTLSFingerprint(candidate) + if err != nil || strings.TrimSpace(fingerprint) == "" { + log.Debug(). + Str("type", nodeType). + Str("candidate", candidate). + Err(err). + Msg("Auto-register candidate not reachable for fingerprint capture") + continue + } + if idx > 0 { + log.Info(). + Str("type", nodeType). + Str("selectedHost", candidate). + Str("requestedHost", candidates[0]). + Msg("Auto-register switched to fallback host candidate reachable from Pulse") + } + return candidate, fingerprint, candidates, nil + } + + return candidates[0], "", candidates, nil +} + func canonicalAutoRegisterMatchMessage(reason string) string { return "Canonical auto-register matched existing node by " + reason } @@ -614,19 +682,19 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h req.ServerName = serverName req.Source = registrationSource - host, err := normalizeNodeHost(req.Host, req.Type) + host, fingerprint, candidateHosts, err := selectAutoRegisterHost(req.Type, req.Host, req.CandidateHosts) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - fingerprint := "" - if fp, err := fetchTLSFingerprint(host); err != nil { - log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register") - } else { - fingerprint = fp - } verifySSL := true + log.Info(). + Str("requestedHost", req.Host). + Str("selectedHost", host). + Strs("candidateHosts", candidateHosts). + Bool("verifySSL", verifySSL). + Msg("Resolved canonical auto-register host candidates") fullTokenID := req.TokenID tokenValue = req.TokenValue diff --git a/internal/hostagent/proxmox_setup.go b/internal/hostagent/proxmox_setup.go index 39d7496da..b39fc7e27 100644 --- a/internal/hostagent/proxmox_setup.go +++ b/internal/hostagent/proxmox_setup.go @@ -56,13 +56,14 @@ type autoRegisterSource string const autoRegisterSourceAgent autoRegisterSource = "agent" type autoRegisterRequest struct { - Type proxmoxProductType `json:"type"` - Host string `json:"host"` - ServerName string `json:"serverName"` - AuthToken string `json:"authToken,omitempty"` - TokenID string `json:"tokenId"` - TokenValue string `json:"tokenValue"` - Source autoRegisterSource `json:"source"` + Type proxmoxProductType `json:"type"` + Host string `json:"host"` + CandidateHosts []string `json:"candidateHosts,omitempty"` + ServerName string `json:"serverName"` + AuthToken string `json:"authToken,omitempty"` + TokenID string `json:"tokenId"` + TokenValue string `json:"tokenValue"` + Source autoRegisterSource `json:"source"` } type autoRegisterResponse struct { @@ -382,6 +383,9 @@ func (p *ProxmoxSetup) Run(ctx context.Context) (*ProxmoxSetupResult, error) { registered = true p.markAsRegistered() p.markTypeAsRegistered(ptype) + if strings.TrimSpace(registerResp.Host) != "" { + hostURL = registerResp.Host + } p.logger.Info().Str("host", hostURL).Str("node", registerResp.NodeName).Msg("Successfully registered Proxmox node with Pulse") } @@ -484,6 +488,9 @@ func (p *ProxmoxSetup) runForType(ctx context.Context, ptype proxmoxProductType) } else { registered = true p.markTypeAsRegistered(ptype) + if strings.TrimSpace(registerResp.Host) != "" { + hostURL = registerResp.Host + } p.logger.Info().Str("type", string(ptype)).Str("host", hostURL).Str("node", registerResp.NodeName).Msg("Successfully registered Proxmox node with Pulse") } @@ -685,10 +692,19 @@ func (p *ProxmoxSetup) parsePBSTokenValue(output string) string { return "" } -// getHostURL constructs the host URL for this Proxmox node. -// Prefers canonical hostname continuity when it resolves to a real local address, -// then falls back to route-aware IP detection and heuristic local IP selection. -func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) string { +func appendUniqueHostCandidate(candidates []string, seen map[string]struct{}, candidate string) []string { + normalized := strings.TrimSpace(candidate) + if normalized == "" { + return candidates + } + if _, exists := seen[normalized]; exists { + return candidates + } + seen[normalized] = struct{}{} + return append(candidates, normalized) +} + +func (p *ProxmoxSetup) candidateHostURLs(ctx context.Context, ptype proxmoxProductType) []string { port := "8006" if ptype == proxmoxProductPBS { port = "8007" @@ -698,12 +714,29 @@ func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) ctx = context.Background() } + candidates := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + buildURL := func(host string) string { + if strings.TrimSpace(host) == "" { + return "" + } + return formatHTTPSURL(host, port) + } + // Priority 1: User-specified ReportIP override from configuration. // This allows users to manually specify which IP should be used for Proxmox API // connections when auto-detection picks the wrong one (e.g., Issue #1061). if p.reportIP != "" { p.logger.Info().Str("ip", p.reportIP).Msg("Using user-specified ReportIP for Proxmox registration") - return formatHTTPSURL(p.reportIP, port) + return append(candidates, buildURL(p.reportIP)) + } + + if p.collector == nil { + hostname := strings.TrimSpace(p.hostname) + if hostname == "" { + hostname = "localhost" + } + return append(candidates, buildURL(hostname)) } // Priority 2: Prefer the system hostname when it resolves to a non-loopback, @@ -715,7 +748,7 @@ func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) Str("hostname", hostname). Str("ip", hostnameIP). Msg("Using resolvable hostname for Proxmox registration") - return formatHTTPSURL(hostname, port) + candidates = appendUniqueHostCandidate(candidates, seen, buildURL(hostname)) } } @@ -723,7 +756,7 @@ func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) // This remains the best fallback when the hostname is not usable. if reachableIP := p.getIPThatReachesPulse(); reachableIP != "" { p.logger.Debug().Str("ip", reachableIP).Msg("Using IP that can reach Pulse server") - return formatHTTPSURL(reachableIP, port) + candidates = appendUniqueHostCandidate(candidates, seen, buildURL(reachableIP)) } // Priority 4: Get all IPs and select the best one based on heuristics. @@ -738,23 +771,41 @@ func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) bestIP := selectBestIP(ips, hostnameIP) if bestIP != "" { - return formatHTTPSURL(bestIP, port) + candidates = appendUniqueHostCandidate(candidates, seen, buildURL(bestIP)) } } } + if len(candidates) > 0 { + return candidates + } + // Final fallback to hostname if IP detection failed hostname := p.hostname if hostname == "" { hostname = "localhost" } - return formatHTTPSURL(hostname, port) + return append(candidates, buildURL(hostname)) +} + +// getHostURL constructs the host URL for this Proxmox node. +// Prefers canonical hostname continuity when it resolves to a real local address, +// then falls back to route-aware IP detection and heuristic local IP selection. +func (p *ProxmoxSetup) getHostURL(ctx context.Context, ptype proxmoxProductType) string { + candidates := p.candidateHostURLs(ctx, ptype) + if len(candidates) == 0 { + return "" + } + return candidates[0] } // getIPThatReachesPulse determines which local IP is used to connect to the Pulse server. // This handles cases where multiple network interfaces exist (e.g., management, Ceph, cluster ring) // and ensures we pick the one that can actually reach Pulse. Related to #929. func (p *ProxmoxSetup) getIPThatReachesPulse() string { + if p.collector == nil { + return "" + } if p.pulseURL == "" { return "" } @@ -819,6 +870,9 @@ func formatHTTPSURL(host, port string) string { // getIPForHostname resolves the system hostname to an IP address. func (p *ProxmoxSetup) getIPForHostname() string { + if p.collector == nil { + return "" + } hostname := p.hostname if hostname == "" { var err error @@ -978,7 +1032,20 @@ type permanentError struct { func (e *permanentError) Error() string { return e.err.Error() } func (e *permanentError) Unwrap() error { return e.err } -func (p *ProxmoxSetup) doRegisterRequest(ctx context.Context, body []byte, expectedType proxmoxProductType, expectedSource autoRegisterSource, expectedHost, expectedTokenID, expectedTokenValue string) (autoRegisterResponse, error) { +func autoRegisterResponseMatchesCandidateHost(responseHost string, candidateHosts []string) bool { + trimmedResponseHost := strings.TrimSpace(responseHost) + if trimmedResponseHost == "" { + return false + } + for _, candidate := range candidateHosts { + if trimmedResponseHost == strings.TrimSpace(candidate) { + return true + } + } + return false +} + +func (p *ProxmoxSetup) doRegisterRequest(ctx context.Context, body []byte, expectedType proxmoxProductType, expectedSource autoRegisterSource, expectedHosts []string, expectedTokenID, expectedTokenValue string) (autoRegisterResponse, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.pulseURL+"/api/auto-register", bytes.NewReader(body)) if err != nil { return autoRegisterResponse{}, &permanentError{fmt.Errorf("create request: %w", err)} @@ -1020,7 +1087,7 @@ func (p *ProxmoxSetup) doRegisterRequest(ctx context.Context, body []byte, expec if strings.TrimSpace(parsed.Source) != strings.TrimSpace(string(expectedSource)) { return autoRegisterResponse{}, fmt.Errorf("auto-register response source mismatch") } - if strings.TrimSpace(parsed.Host) != strings.TrimSpace(expectedHost) { + if !autoRegisterResponseMatchesCandidateHost(parsed.Host, expectedHosts) { return autoRegisterResponse{}, fmt.Errorf("auto-register response host mismatch") } if strings.TrimSpace(parsed.NodeID) == "" { @@ -1141,6 +1208,17 @@ func (p *ProxmoxSetup) fetchSetupToken(ctx context.Context, ptype proxmoxProduct // registerWithPulse calls the auto-register endpoint to add the node. func (p *ProxmoxSetup) registerWithPulse(ctx context.Context, ptype proxmoxProductType, hostURL, tokenID, tokenValue string) (autoRegisterResponse, error) { + candidateHosts := p.candidateHostURLs(ctx, ptype) + if len(candidateHosts) == 0 || strings.TrimSpace(candidateHosts[0]) != strings.TrimSpace(hostURL) { + seen := make(map[string]struct{}, len(candidateHosts)+1) + reordered := make([]string, 0, len(candidateHosts)+1) + reordered = appendUniqueHostCandidate(reordered, seen, hostURL) + for _, candidate := range candidateHosts { + reordered = appendUniqueHostCandidate(reordered, seen, candidate) + } + candidateHosts = reordered + } + backoffs := p.retryBackoffs if backoffs == nil { backoffs = []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second, 60 * time.Second} @@ -1178,13 +1256,14 @@ func (p *ProxmoxSetup) registerWithPulse(ctx context.Context, ptype proxmoxProdu } payload := autoRegisterRequest{ - Type: ptype, - Host: hostURL, - ServerName: p.hostname, - AuthToken: setupToken, - TokenID: tokenID, - TokenValue: tokenValue, - Source: autoRegisterSourceAgent, + Type: ptype, + Host: hostURL, + CandidateHosts: candidateHosts, + ServerName: p.hostname, + AuthToken: setupToken, + TokenID: tokenID, + TokenValue: tokenValue, + Source: autoRegisterSourceAgent, } body, err := json.Marshal(payload) @@ -1192,7 +1271,7 @@ func (p *ProxmoxSetup) registerWithPulse(ctx context.Context, ptype proxmoxProdu return autoRegisterResponse{}, fmt.Errorf("marshal payload: %w", err) } - parsed, err := p.doRegisterRequest(ctx, body, ptype, autoRegisterSourceAgent, hostURL, tokenID, tokenValue) + parsed, err := p.doRegisterRequest(ctx, body, ptype, autoRegisterSourceAgent, candidateHosts, tokenID, tokenValue) if err == nil { if attempt > 0 { p.logger.Info().Int("attempt", attempt+1).Str("type", string(ptype)).Msg("Proxmox auto-registration succeeded after retry") diff --git a/internal/hostagent/proxmox_setup_test.go b/internal/hostagent/proxmox_setup_test.go index ea1ab5779..0a70009ce 100644 --- a/internal/hostagent/proxmox_setup_test.go +++ b/internal/hostagent/proxmox_setup_test.go @@ -145,6 +145,15 @@ func TestRegisterWithPulse_Payload(t *testing.T) { if gotPayload.Host != "https://10.0.0.4:8006" { t.Fatalf("unexpected host %q", gotPayload.Host) } + if len(gotPayload.CandidateHosts) != 2 { + t.Fatalf("unexpected candidateHosts %#v", gotPayload.CandidateHosts) + } + if gotPayload.CandidateHosts[0] != "https://10.0.0.4:8006" { + t.Fatalf("unexpected primary candidateHost %q", gotPayload.CandidateHosts[0]) + } + if gotPayload.CandidateHosts[1] != "https://node-1:8006" { + t.Fatalf("unexpected candidateHosts %#v", gotPayload.CandidateHosts) + } if gotPayload.ServerName != "node-1" { t.Fatalf("unexpected serverName %q", gotPayload.ServerName) } @@ -171,6 +180,73 @@ func TestRegisterWithPulse_Payload(t *testing.T) { } } +func TestRegisterWithPulse_AcceptsServerSelectedFallbackHost(t *testing.T) { + var gotPayload autoRegisterRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method %s", r.Method) + } + switch r.URL.Path { + case "/api/setup-script-url": + publicURL := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(canonicalSetupScriptURLResponseJSON(publicURL, "pve", "https://node-1:8006", "setup-token-123"))) + return + case "/api/auto-register": + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success","message":"Node node-1 registered successfully at https://10.0.0.4:8006","action":"use_token","type":"pve","source":"agent","host":"https://10.0.0.4:8006","nodeId":"node-1","nodeName":"node-1","tokenId":"token-id","tokenValue":"token-value"}`)) + return + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + mc := &mockCollector{} + mc.lookupIPFn = func(host string) ([]net.IP, error) { + if host == "node-1" { + return []net.IP{net.ParseIP("10.9.0.10")}, nil + } + return nil, nil + } + mc.dialTimeoutFn = func(network, address string, timeout time.Duration) (net.Conn, error) { + return &mockConn{localAddr: &net.UDPAddr{IP: net.ParseIP("10.0.0.4")}}, nil + } + mc.commandCombinedOutputFn = func(ctx context.Context, name string, arg ...string) (string, error) { + return "", fmt.Errorf("unused hostname -I fallback") + } + + p := &ProxmoxSetup{ + logger: zerolog.Nop(), + collector: mc, + httpClient: server.Client(), + pulseURL: server.URL, + apiToken: "pulse-token", + hostname: "node-1", + } + + resp, err := p.registerWithPulse(context.Background(), proxmoxProductPVE, "https://node-1:8006", "token-id", "token-value") + if err != nil { + t.Fatalf("registerWithPulse failed: %v", err) + } + if resp.Host != "https://10.0.0.4:8006" { + t.Fatalf("response host = %q, want fallback host", resp.Host) + } + if len(gotPayload.CandidateHosts) != 2 { + t.Fatalf("candidateHosts len = %d, want 2", len(gotPayload.CandidateHosts)) + } + if gotPayload.CandidateHosts[0] != "https://node-1:8006" { + t.Fatalf("candidateHosts[0] = %q, want hostname candidate first", gotPayload.CandidateHosts[0]) + } + if gotPayload.CandidateHosts[1] != "https://10.0.0.4:8006" { + t.Fatalf("candidateHosts[1] = %q, want route-aware fallback host", gotPayload.CandidateHosts[1]) + } +} + func TestParseTokenValue_RegexFallback(t *testing.T) { p := &ProxmoxSetup{logger: zerolog.Nop()} output := "some noise then 7c5709fb-6aee-4c32-8b9f-5c2656912345 more noise" diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index f4286d74d..f3b146c0d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -38,6 +38,30 @@ PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES = [ ] +def _contract_reference(contract_path: str, needle: str, runtime_path: str) -> dict: + lines = (REPO_ROOT / contract_path).read_text(encoding="utf-8").splitlines() + current_heading = None + current_heading_line = None + + for line_number, line in enumerate(lines, start=1): + if line.startswith("## "): + current_heading = line + current_heading_line = line_number + if needle in line: + if current_heading is None or current_heading_line is None: + raise AssertionError( + f"reference {needle!r} in {contract_path} has no enclosing heading" + ) + return { + "heading": current_heading, + "path": runtime_path, + "line": line_number, + "heading_line": current_heading_line, + } + + raise AssertionError(f"reference {needle!r} not found in {contract_path}") + + class SubsystemLookupTest(unittest.TestCase): def test_parse_args_accepts_lean_flag(self) -> None: args = parse_args(["internal/api/ai_handler.go", "--pretty", "--lean"]) @@ -4630,52 +4654,48 @@ class SubsystemLookupTest(unittest.TestCase): result = lookup_paths(["internal/api/ai_handler.go"]) file_entry = result["files"][0] by_subsystem = {match["subsystem"]: match for match in file_entry["matches"]} + ai_runtime_expected = [ + _contract_reference( + "docs/release-control/v6/internal/subsystems/ai-runtime.md", + "2. `internal/api/ai_handler.go`", + "internal/api/ai_handler.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/ai-runtime.md", + "3. `internal/api/ai_handler.go` shared with `api-contracts`", + "internal/api/ai_handler.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/ai-runtime.md", + "2. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go`", + "internal/api/ai_handler.go", + ), + ] + api_contracts_expected = [ + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "33. `internal/api/ai_handler.go` shared with `ai-runtime`", + "internal/api/ai_handler.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "21. Keep hosted AI settings bootstrap on the shared API contract", + "internal/api/ai_handler.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "22. Keep post-boot AI enablement contract-backed on the shared AI/mobile approval surface", + "internal/api/ai_handler.go", + ), + ] self.assertEqual( by_subsystem["ai-runtime"]["matched_contract_references"], - [ - { - "heading": "## Canonical Files", - "path": "internal/api/ai_handler.go", - "line": 24, - "heading_line": 21, - }, - { - "heading": "## Shared Boundaries", - "path": "internal/api/ai_handler.go", - "line": 45, - "heading_line": 41, - }, - { - "heading": "## Extension Points", - "path": "internal/api/ai_handler.go", - "line": 52, - "heading_line": 49, - }, - ], + ai_runtime_expected, ) self.assertEqual( by_subsystem["api-contracts"]["matched_contract_references"], - [ - { - "heading": "## Shared Boundaries", - "path": "internal/api/ai_handler.go", - "line": 111, - "heading_line": 77, - }, - { - "heading": "## Extension Points", - "path": "internal/api/ai_handler.go", - "line": 174, - "heading_line": 131, - }, - { - "heading": "## Extension Points", - "path": "internal/api/ai_handler.go", - "line": 175, - "heading_line": 131, - }, - ], + api_contracts_expected, ) self.assertEqual( by_subsystem["api-contracts"]["ownership_basis"], @@ -4695,13 +4715,41 @@ class SubsystemLookupTest(unittest.TestCase): self.assertIn("paid-feature-entitlement-gating", api_match["lane_context"]["release_gate_ids"]) self.assertEqual( [reference["line"] for reference in api_match["matched_contract_references"]], - [111, 174, 175], + [ + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "33. `internal/api/ai_handler.go` shared with `ai-runtime`", + "internal/api/ai_handler.go", + )["line"], + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "21. Keep hosted AI settings bootstrap on the shared API contract", + "internal/api/ai_handler.go", + )["line"], + _contract_reference( + "docs/release-control/v6/internal/subsystems/api-contracts.md", + "22. Keep post-boot AI enablement contract-backed on the shared AI/mobile approval surface", + "internal/api/ai_handler.go", + )["line"], + ], ) def test_render_pretty_shows_contract_focus_for_lean_lookup(self) -> None: rendered = render_pretty(lookup_paths(["internal/api/ai_handler.go"], lean=True)) - self.assertIn("contract focus: ## Shared Boundaries @L111: internal/api/ai_handler.go", rendered) - self.assertIn("contract focus: ## Canonical Files @L24: internal/api/ai_handler.go", rendered) + self.assertIn( + "contract focus: " + f"{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '33. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['heading']} " + f"@L{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '33. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['line']}: " + "internal/api/ai_handler.go", + rendered, + ) + self.assertIn( + "contract focus: " + f"{_contract_reference('docs/release-control/v6/internal/subsystems/ai-runtime.md', '2. `internal/api/ai_handler.go`', 'internal/api/ai_handler.go')['heading']} " + f"@L{_contract_reference('docs/release-control/v6/internal/subsystems/ai-runtime.md', '2. `internal/api/ai_handler.go`', 'internal/api/ai_handler.go')['line']}: " + "internal/api/ai_handler.go", + rendered, + ) def test_lookup_paths_maps_unified_agent_runtime_to_agent_lifecycle(self) -> None: result = lookup_paths(["internal/hostagent/agent.go"])