diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6b4b47f3a..b9c62c6d1 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -221,6 +221,12 @@ state instead of inferring it from outbound usage telemetry. Pulse base URL plus `/api/auto-register`, not to the script download artifact URL, so install-time registration keeps one API root regardless of whether the script came from `/api/setup-script` or `/api/setup-script-url`. + Generated PVE setup scripts must prove a freshly created monitoring token + after ACL application and before `/api/auto-register` by calling the local + Proxmox `/api2/json/nodes` endpoint with the exact `PVEAPIToken` header + that would be sent to Pulse. A failed smoke check may leave manual token + details for the operator, but it must not POST an unproven token as a + completed lifecycle registration. 22. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. 23. `internal/kubernetesagent/agent.go` shared with `monitoring`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client. 24. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 619bf275b..b87de78cc 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1531,6 +1531,12 @@ payload shape change when the portal presents compact client rows. receive the standard license-required response, but direct API calls must not bypass Relay entitlement by creating mobile runtime tokens. 80. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection). + PVE setup-script auto-registration is part of the rendered API contract: + after creating the privilege-separated token and applying ACLs, the script + must smoke-test the exact token id/value against + `${HOST_URL%/}/api2/json/nodes` with `PVEAPIToken` authentication before + it posts the canonical `/api/auto-register` payload. Smoke-check failure is + a manual-completion state, not a successful auto-registration response. 81. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. 82. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. 83. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary. @@ -5204,6 +5210,12 @@ That same diagnostics boundary must also backfill canonical fallback reasons when a raw snapshot reaches the API layer without one, so `buildMemorySourceDiagnostics` stays self-consistent even if a caller bypasses `GetDiagnosticSnapshots()` and hands diagnostics a legacy alias directly. +PVE and PBS node diagnostics must also classify connection failures into +machine-readable `errorKind` values with operator guidance in +`troubleshooting`. Missing stored credentials, Proxmox auth rejection, TLS +certificate mismatch, refused connections, unreachable networks, DNS failures, +timeouts, and unknown connection failures must remain distinguishable in +support bundles instead of collapsing into a generic connection failure string. That same diagnostics boundary now explicitly excludes maintainer analytics. `internal/api/diagnostics.go` must not serialize commercial funnel, sales funnel, pricing/checkout conversion, or infrastructure onboarding telemetry in diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 15f6549c4..6a5653f44 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -390,6 +390,12 @@ TLS floor in the dynamic config. `--token-file` runtime path, and explicit update URL arguments must not suppress legacy recovery of the remaining connection, identity, feature, or trust fields. + Root `install.sh` Proxmox server auto-registration must not persist a newly + created monitoring token into Pulse until the installer has applied the + token ACLs and smoke-tested that exact `PVEAPIToken` against the local + Proxmox `/api2/json/nodes` endpoint. A failed token smoke check must leave + the installer in a manual-completion state instead of POSTing + `/api/auto-register`. Deployment bootstrap token behavior remains a deployment-installability trust boundary even when the handler is API-owned. `internal/api/deploy_handlers.go` must preserve server-derived `owner_user_id` lineage on bootstrap tokens and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 26635bd4e..e64dea713 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -166,6 +166,11 @@ auto-registration changes that affect backup visibility permissions are storage/recovery-adjacent: optional PVE `/storage` grants must remain effective for privilege-separated tokens by assigning the same `PVEDatastoreAdmin` role to both the service user and the concrete token id. +Generated PVE setup-script smoke checks must run after those concrete token +grants have been applied and must not skip or reorder the optional `/storage` +backup-visibility grant path. If the smoke check fails, the script may fall +back to manual completion, but the displayed token details must still describe +the same ACL state that would have been submitted to Pulse. The generated Proxmox Audit/Repair path is storage/recovery-adjacent for the same reason: when backup visibility was requested, repair must reapply the optional `/storage` grant to the service user and to the current concrete token diff --git a/install.sh b/install.sh index e3ad570d2..ab4baaa6f 100755 --- a/install.sh +++ b/install.sh @@ -2138,6 +2138,28 @@ create_pve_auto_register_token() { printf -v "$token_status_var" '%s' "$token_status" } +smoke_test_pve_auto_register_token() { + local host_url="$1" + local token_id="$2" + local token_value="$3" + local smoke_output="" + local smoke_status=0 + + set +e + smoke_output=$(curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes" 2>&1) + smoke_status=$? + set -e + + if [[ $smoke_status -ne 0 ]]; then + AUTO_NODE_REGISTER_ERROR="token smoke check failed" + print_warn "Created Proxmox monitoring token, but a local API smoke check failed; skipping automatic node registration" + print_warn "Smoke check error: ${smoke_output}" + return 1 + fi + + return 0 +} + auto_register_pve_node() { local ctid="$1" local pulse_ip="$2" @@ -2481,6 +2503,10 @@ PY pveum aclmod / -token "$token_id" -role PulseMonitor >/dev/null 2>&1 || true fi + if ! smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"; then + return + fi + local register_payload register_payload=$(python3 - <<'PY' "$normalized_host_url" "$token_id" "$token_value" "$server_name" "$setup_token" import json, sys diff --git a/internal/api/config_handlers_setup_script_test.go b/internal/api/config_handlers_setup_script_test.go index f9340f32c..8a4eb1f62 100644 --- a/internal/api/config_handlers_setup_script_test.go +++ b/internal/api/config_handlers_setup_script_test.go @@ -901,11 +901,15 @@ func TestPVESetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T) t.Fatalf("expected PVE manual footer to be gated on usable token extraction, got: %s", truncate(script, 1100)) } if !containsString(script, `if [ "$TOKEN_READY" = true ]; then - attempt_auto_registration + if smoke_test_pve_token; then + attempt_auto_registration + else + AUTO_REG_SUCCESS=false + fi else AUTO_REG_SUCCESS=false fi`) { - t.Fatalf("expected PVE auto-registration to be skipped when no usable token is ready, got: %s", truncate(script, 1400)) + t.Fatalf("expected PVE auto-registration to be skipped when no usable token or smoke-tested token is ready, got: %s", truncate(script, 1400)) } if containsString(script, `elif [ "$TOKEN_READY" != true ]; then echo "Pulse monitoring token setup completed."`) { @@ -913,6 +917,53 @@ fi`) { } } +func TestPVESetupScriptSmokeTestsCreatedTokenBeforeAutoRegistration(t *testing.T) { + tempDir := t.TempDir() + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + } + + handlers := newTestConfigHandlers(t, cfg) + + req := httptest.NewRequest(http.MethodGet, + "/api/setup-script?type=pve&host=http://sentinel-host:8006&pulse_url=http://sentinel-url:7656&setup_token=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", nil) + rr := httptest.NewRecorder() + + handlers.HandleSetupScript(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d (%s)", rr.Code, rr.Body.String()) + } + + script := rr.Body.String() + for _, needle := range []string{ + `smoke_test_pve_token() {`, + `curl -kfsS --retry 2 --retry-delay 1`, + `Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`, + `${HOST_URL%/}/api2/json/nodes`, + `TOKEN_READY=false`, + `if smoke_test_pve_token; then`, + } { + if !containsString(script, needle) { + t.Fatalf("expected PVE setup script token smoke-check contract %q, got: %s", needle, truncate(script, 1200)) + } + } + + permissionsIdx := strings.Index(script, `configure_pve_pulse_monitor_role "$TOKEN_CREATED"`) + smokeCallIdx := strings.Index(script, `if smoke_test_pve_token; then`) + registerCallIdx := -1 + if smokeCallIdx >= 0 { + registerCallIdx = strings.Index(script[smokeCallIdx:], `attempt_auto_registration`) + if registerCallIdx >= 0 { + registerCallIdx += smokeCallIdx + } + } + if permissionsIdx < 0 || smokeCallIdx < 0 || registerCallIdx < 0 || !(permissionsIdx < smokeCallIdx && smokeCallIdx < registerCallIdx) { + t.Fatalf("expected PVE setup script to smoke-test token after permissions and before auto-registration (perms=%d smoke=%d register=%d)", permissionsIdx, smokeCallIdx, registerCallIdx) + } +} + func containsString(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (findSubstring(s, substr) >= 0)) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a48c51ef7..04bcc8be5 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -752,6 +752,10 @@ fi` `pveum aclmod / -token "$PULSE_TOKEN_ID" -role PulseMonitor`, `pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin`, `pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin`, + `smoke_test_pve_token() {`, + `Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE`, + `${HOST_URL%/}/api2/json/nodes`, + `if smoke_test_pve_token; then`, } { if !strings.Contains(pveScript, required) { t.Fatalf("PVE setup script must contain %q", required) @@ -6296,11 +6300,15 @@ func TestContract_SetupScriptEmbedsFailFastGuidance(t *testing.T) { t.Fatalf("setup script missing token-extract completion rerun guidance: %s", script) } if !strings.Contains(script, `if [ "$TOKEN_READY" = true ]; then - attempt_auto_registration + if smoke_test_pve_token; then + attempt_auto_registration + else + AUTO_REG_SUCCESS=false + fi else AUTO_REG_SUCCESS=false fi`) { - t.Fatalf("setup script does not skip PVE auto-registration when no usable token is ready: %s", script) + t.Fatalf("setup script does not skip PVE auto-registration when no usable or smoke-tested token is ready: %s", script) } if !strings.Contains(script, `if [ "$TOKEN_READY" = true ]; then echo "Add this server to Pulse with:"`) { diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 21e3934e3..3ebed717a 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -434,18 +434,20 @@ func diagnosticsScopeKey(ctx context.Context) string { // NodeDiagnostic contains diagnostic info for a Proxmox node type NodeDiagnostic struct { - ID string `json:"id"` - Name string `json:"name"` - Host string `json:"host"` - Type string `json:"type"` - AuthMethod string `json:"authMethod"` - Connected bool `json:"connected"` - Error string `json:"error,omitempty"` - Details *NodeDetails `json:"details,omitempty"` - LastPoll string `json:"lastPoll,omitempty"` - ClusterInfo *ClusterInfo `json:"clusterInfo,omitempty"` - VMDiskCheck *VMDiskCheckResult `json:"vmDiskCheck,omitempty"` - PhysicalDisks *PhysicalDiskCheck `json:"physicalDisks,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Type string `json:"type"` + AuthMethod string `json:"authMethod"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` + ErrorKind string `json:"errorKind,omitempty"` + Troubleshooting string `json:"troubleshooting,omitempty"` + Details *NodeDetails `json:"details,omitempty"` + LastPoll string `json:"lastPoll,omitempty"` + ClusterInfo *ClusterInfo `json:"clusterInfo,omitempty"` + VMDiskCheck *VMDiskCheckResult `json:"vmDiskCheck,omitempty"` + PhysicalDisks *PhysicalDiskCheck `json:"physicalDisks,omitempty"` } func (d NodeDiagnostic) NormalizeCollections() NodeDiagnostic { @@ -557,12 +559,15 @@ type ClusterInfo struct { // PBSDiagnostic contains diagnostic info for a PBS instance type PBSDiagnostic struct { - ID string `json:"id"` - Name string `json:"name"` - Host string `json:"host"` - Connected bool `json:"connected"` - Error string `json:"error,omitempty"` - Details *PBSDetails `json:"details,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + AuthMethod string `json:"authMethod"` + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` + ErrorKind string `json:"errorKind,omitempty"` + Troubleshooting string `json:"troubleshooting,omitempty"` + Details *PBSDetails `json:"details,omitempty"` } // PBSDetails contains PBS-specific details @@ -821,6 +826,129 @@ func mergeDiagnosticsConnection(probeConnected bool, probeError string, monitorC return true, "Live diagnostics probe failed, but the monitor still reports this instance connected: " + probeError } +type diagnosticsConnectionFailure struct { + kind string + message string + troubleshooting string +} + +func diagnosticsAuthMethod(user, password, tokenName, tokenValue string) string { + if tokenName != "" && tokenValue != "" { + return "api_token" + } + if user != "" && password != "" { + return "username_password" + } + return "none" +} + +func missingStoredSecretDiagnosticsFailure() diagnosticsConnectionFailure { + return diagnosticsConnectionFailure{ + kind: "no_stored_secret", + message: "Pulse has no stored credentials for this node", + troubleshooting: "Add or rotate this node's API token or credentials in Settings.", + } +} + +func classifyDiagnosticsConnectionFailure(err error, fallbackMessage string) diagnosticsConnectionFailure { + raw := "" + if err != nil { + raw = strings.TrimSpace(err.Error()) + } + lower := strings.ToLower(raw) + + if strings.Contains(lower, "invalid") && strings.Contains(lower, "host") { + return diagnosticsConnectionFailure{ + kind: "invalid_host_url", + message: "Stored node URL is invalid", + troubleshooting: "Check the node host URL in Settings, including the scheme and port.", + } + } + if strings.Contains(lower, "connection refused") { + return diagnosticsConnectionFailure{ + kind: "connection_refused", + message: "TCP connection refused by host", + troubleshooting: "Check the node host URL and port, then confirm the Proxmox API service is listening and reachable from Pulse.", + } + } + if strings.Contains(lower, "no route to host") || + strings.Contains(lower, "network is unreachable") || + strings.Contains(lower, "host is down") { + return diagnosticsConnectionFailure{ + kind: "network_unreachable", + message: "Network route to host is unavailable", + troubleshooting: "Check DNS, routing, firewall rules, and the host URL from the Pulse server.", + } + } + if strings.Contains(lower, "no such host") || + strings.Contains(lower, "temporary failure in name resolution") || + strings.Contains(lower, "name or service not known") { + return diagnosticsConnectionFailure{ + kind: "dns_resolution_failed", + message: "Node hostname could not be resolved", + troubleshooting: "Check the node hostname in Settings and verify DNS resolution from the Pulse server.", + } + } + if strings.Contains(lower, "certificate") || + strings.Contains(lower, "x509") || + strings.Contains(lower, "tls:") || + strings.Contains(lower, "fingerprint") { + return diagnosticsConnectionFailure{ + kind: "tls_certificate_mismatch", + message: "TLS certificate mismatch or untrusted certificate", + troubleshooting: "Update the stored certificate fingerprint, fix the node certificate, or disable verification only on a trusted network.", + } + } + if strings.Contains(lower, "authentication") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "forbidden") || + strings.Contains(lower, "invalid credentials") || + strings.Contains(lower, "invalid token") || + strings.Contains(lower, "permission denied") || + strings.Contains(lower, "401") || + strings.Contains(lower, "403") { + return diagnosticsConnectionFailure{ + kind: "proxmox_auth_rejected", + message: "Proxmox rejected the stored API token or credentials", + troubleshooting: "Rotate the node credentials in Settings, then verify the user or token has the required Proxmox permissions.", + } + } + if strings.Contains(lower, "context deadline exceeded") || + strings.Contains(lower, "client.timeout exceeded") || + strings.Contains(lower, "i/o timeout") || + strings.Contains(lower, "tls handshake timeout") || + strings.Contains(lower, "timeout awaiting response headers") { + return diagnosticsConnectionFailure{ + kind: "connection_timeout", + message: "Connection timed out", + troubleshooting: "Check network latency, API responsiveness, and slow or unreachable backend storage from the Pulse server.", + } + } + + if fallbackMessage == "" { + fallbackMessage = "Connection check failed" + } + return diagnosticsConnectionFailure{ + kind: "connection_failed", + message: fallbackMessage, + troubleshooting: "Review Pulse logs for the full transport error, then verify the endpoint from the Pulse server.", + } +} + +func applyNodeDiagnosticFailure(diag *NodeDiagnostic, failure diagnosticsConnectionFailure) { + diag.Connected = false + diag.Error = failure.message + diag.ErrorKind = failure.kind + diag.Troubleshooting = failure.troubleshooting +} + +func applyPBSDiagnosticFailure(diag *PBSDiagnostic, failure diagnosticsConnectionFailure) { + diag.Connected = false + diag.Error = failure.message + diag.ErrorKind = failure.kind + diag.Troubleshooting = failure.troubleshooting +} + func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { diag := EmptyDiagnosticsInfo() @@ -869,13 +997,11 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { monitorConnected, hasMonitorStatus := diagnosticsMonitorConnectionStatus(r.monitor, "pve-"+pveKeyName) // Determine auth method (sanitized - don't expose actual values) - if node.TokenName != "" && node.TokenValue != "" { - nodeDiag.AuthMethod = "api_token" - } else if node.User != "" && node.Password != "" { - nodeDiag.AuthMethod = "username_password" - } else { - nodeDiag.AuthMethod = "none" - nodeDiag.Error = "No authentication configured" + nodeDiag.AuthMethod = diagnosticsAuthMethod(node.User, node.Password, node.TokenName, node.TokenValue) + if nodeDiag.AuthMethod == "none" { + applyNodeDiagnosticFailure(&nodeDiag, missingStoredSecretDiagnosticsFailure()) + diag.Nodes = append(diag.Nodes, nodeDiag) + continue } // Test connection @@ -891,15 +1017,15 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { client, err := proxmox.NewClient(testCfg) if err != nil { - nodeDiag.Connected = false - nodeDiag.Error = "Failed to initialize connection" + failure := classifyDiagnosticsConnectionFailure(err, "Failed to initialize connection") + applyNodeDiagnosticFailure(&nodeDiag, failure) log.Error().Err(err).Str("node", node.Name).Msg("Diagnostics: Proxmox client init failed") nodeDiag.Connected, nodeDiag.Error = mergeDiagnosticsConnection(nodeDiag.Connected, nodeDiag.Error, monitorConnected, hasMonitorStatus) } else { nodes, err := client.GetNodes(ctx) if err != nil { - nodeDiag.Connected = false - nodeDiag.Error = "Failed to connect to Proxmox API" + failure := classifyDiagnosticsConnectionFailure(err, "Failed to connect to Proxmox API") + applyNodeDiagnosticFailure(&nodeDiag, failure) log.Error().Err(err).Str("node", node.Name).Msg("Diagnostics: Proxmox API connection failed") nodeDiag.Connected, nodeDiag.Error = mergeDiagnosticsConnection(nodeDiag.Connected, nodeDiag.Error, monitorConnected, hasMonitorStatus) } else { @@ -935,15 +1061,21 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { // Test PBS instances for _, pbsNode := range r.config.PBSInstances { pbsDiag := PBSDiagnostic{ - ID: pbsNode.Name, - Name: pbsNode.Name, - Host: pbsNode.Host, + ID: pbsNode.Name, + Name: pbsNode.Name, + Host: pbsNode.Host, + AuthMethod: diagnosticsAuthMethod(pbsNode.User, pbsNode.Password, pbsNode.TokenName, pbsNode.TokenValue), } pbsKeyName := pbsNode.Name if strings.TrimSpace(pbsKeyName) == "" { pbsKeyName = pbsNode.Host } monitorConnected, hasMonitorStatus := diagnosticsMonitorConnectionStatus(r.monitor, "pbs-"+pbsKeyName) + if pbsDiag.AuthMethod == "none" { + applyPBSDiagnosticFailure(&pbsDiag, missingStoredSecretDiagnosticsFailure()) + diag.PBS = append(diag.PBS, pbsDiag) + continue + } testCfg := pbs.ClientConfig{ Host: pbsNode.Host, @@ -957,14 +1089,14 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { client, err := pbs.NewClient(testCfg) if err != nil { - pbsDiag.Connected = false - pbsDiag.Error = "Failed to initialize connection" + failure := classifyDiagnosticsConnectionFailure(err, "Failed to initialize connection") + applyPBSDiagnosticFailure(&pbsDiag, failure) log.Error().Err(err).Str("pbs", pbsNode.Name).Msg("Diagnostics: PBS client init failed") pbsDiag.Connected, pbsDiag.Error = mergeDiagnosticsConnection(pbsDiag.Connected, pbsDiag.Error, monitorConnected, hasMonitorStatus) } else { if version, err := client.GetVersion(ctx); err != nil { - pbsDiag.Connected = false - pbsDiag.Error = "Connection established but version check failed" + failure := classifyDiagnosticsConnectionFailure(err, "Connection established but version check failed") + applyPBSDiagnosticFailure(&pbsDiag, failure) log.Error().Err(err).Str("pbs", pbsNode.Name).Msg("Diagnostics: PBS version check failed") pbsDiag.Connected, pbsDiag.Error = mergeDiagnosticsConnection(pbsDiag.Connected, pbsDiag.Error, monitorConnected, hasMonitorStatus) } else { diff --git a/internal/api/diagnostics_additional_test.go b/internal/api/diagnostics_additional_test.go index cfc682e2c..070e4548b 100644 --- a/internal/api/diagnostics_additional_test.go +++ b/internal/api/diagnostics_additional_test.go @@ -94,6 +94,57 @@ func newMonitorForDiagnostics(t *testing.T, cfg *config.Config) *monitoring.Moni return monitor } +func TestComputeDiagnosticsClassifiesMissingNodeSecrets(t *testing.T) { + tempDir := t.TempDir() + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + PVEInstances: []config.PVEInstance{{ + Name: "pve-no-secret", + Host: "https://pve-no-secret.example:8006", + }}, + PBSInstances: []config.PBSInstance{{ + Name: "pbs-no-secret", + Host: "https://pbs-no-secret.example:8007", + }}, + } + monitor := newMonitorForDiagnostics(t, cfg) + router := &Router{config: cfg, monitor: monitor} + + diag := router.computeDiagnostics(context.Background()) + if len(diag.Nodes) != 1 { + t.Fatalf("expected one PVE diagnostic, got %d", len(diag.Nodes)) + } + if got := diag.Nodes[0].AuthMethod; got != "none" { + t.Fatalf("PVE auth method = %q, want none", got) + } + if got := diag.Nodes[0].ErrorKind; got != "no_stored_secret" { + t.Fatalf("PVE error kind = %q, want no_stored_secret", got) + } + if !strings.Contains(diag.Nodes[0].Error, "no stored credentials") { + t.Fatalf("PVE error should describe missing credentials, got %q", diag.Nodes[0].Error) + } + if strings.TrimSpace(diag.Nodes[0].Troubleshooting) == "" { + t.Fatal("expected PVE troubleshooting guidance") + } + + if len(diag.PBS) != 1 { + t.Fatalf("expected one PBS diagnostic, got %d", len(diag.PBS)) + } + if got := diag.PBS[0].AuthMethod; got != "none" { + t.Fatalf("PBS auth method = %q, want none", got) + } + if got := diag.PBS[0].ErrorKind; got != "no_stored_secret" { + t.Fatalf("PBS error kind = %q, want no_stored_secret", got) + } + if !strings.Contains(diag.PBS[0].Error, "no stored credentials") { + t.Fatalf("PBS error should describe missing credentials, got %q", diag.PBS[0].Error) + } + if strings.TrimSpace(diag.PBS[0].Troubleshooting) == "" { + t.Fatal("expected PBS troubleshooting guidance") + } +} + func TestHandleDiagnostics_CacheHit(t *testing.T) { cached := DiagnosticsInfo{Version: "cached"} cachedAt := time.Now() diff --git a/internal/api/diagnostics_test.go b/internal/api/diagnostics_test.go index d17e9ce90..33bb2d90d 100644 --- a/internal/api/diagnostics_test.go +++ b/internal/api/diagnostics_test.go @@ -1,6 +1,7 @@ package api import ( + "errors" "os" "strings" "testing" @@ -798,3 +799,70 @@ func TestMergeDiagnosticsConnection(t *testing.T) { }) } } + +func TestClassifyDiagnosticsConnectionFailure(t *testing.T) { + cases := []struct { + name string + err error + fallback string + wantKind string + wantMessage string + }{ + { + name: "auth rejected", + err: errors.New("API error 401 (Unauthorized): Invalid credentials or token"), + fallback: "Failed to connect to Proxmox API", + wantKind: "proxmox_auth_rejected", + wantMessage: "Proxmox rejected the stored API token or credentials", + }, + { + name: "tls certificate mismatch", + err: errors.New("x509: certificate is valid for pve.local, not 192.0.2.10"), + fallback: "Failed to connect to Proxmox API", + wantKind: "tls_certificate_mismatch", + wantMessage: "TLS certificate mismatch or untrusted certificate", + }, + { + name: "connection refused", + err: errors.New("dial tcp 192.0.2.10:8006: connect: connection refused"), + fallback: "Failed to connect to Proxmox API", + wantKind: "connection_refused", + wantMessage: "TCP connection refused by host", + }, + { + name: "network unreachable", + err: errors.New("dial tcp 192.0.2.10:8006: connect: no route to host"), + fallback: "Failed to connect to Proxmox API", + wantKind: "network_unreachable", + wantMessage: "Network route to host is unavailable", + }, + { + name: "timeout", + err: errors.New("context deadline exceeded"), + fallback: "Failed to connect to Proxmox API", + wantKind: "connection_timeout", + wantMessage: "Connection timed out", + }, + { + name: "fallback", + err: errors.New("unexpected EOF"), + fallback: "Failed to connect to Proxmox API", + wantKind: "connection_failed", + wantMessage: "Failed to connect to Proxmox API", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := classifyDiagnosticsConnectionFailure(tc.err, tc.fallback) + if got.kind != tc.wantKind { + t.Fatalf("kind = %q, want %q", got.kind, tc.wantKind) + } + if got.message != tc.wantMessage { + t.Fatalf("message = %q, want %q", got.message, tc.wantMessage) + } + if strings.TrimSpace(got.troubleshooting) == "" { + t.Fatal("expected troubleshooting guidance") + } + }) + } +} diff --git a/internal/api/setup_script_render.go b/internal/api/setup_script_render.go index 3b5a81b67..762a0799b 100644 --- a/internal/api/setup_script_render.go +++ b/internal/api/setup_script_render.go @@ -683,6 +683,28 @@ create_pve_token() { return "$TOKEN_CREATE_RC" } +smoke_test_pve_token() { + if SMOKE_OUTPUT=$(curl -kfsS --retry 2 --retry-delay 1 \ + -H "Authorization: PVEAPIToken=$PULSE_TOKEN_ID=$TOKEN_VALUE" \ + "${HOST_URL%%/}/api2/json/nodes" 2>&1); then + SMOKE_RC=0 + else + SMOKE_RC=$? + fi + + if [ "$SMOKE_RC" -ne 0 ]; then + echo "⚠️ Created API token, but the local Proxmox API smoke check failed." + echo " Response: $SMOKE_OUTPUT" + echo "" + echo "📝 Use the token details below in Pulse Settings → Nodes after resolving the API connectivity issue." + TOKEN_READY=false + AUTO_REG_SUCCESS=false + return 1 + fi + + return 0 +} + attempt_auto_registration() { if [ -z "$PULSE_SETUP_TOKEN" ]; then if [ -t 0 ]; then @@ -811,7 +833,11 @@ fi%s configure_pve_pulse_monitor_role "$TOKEN_CREATED" if [ "$TOKEN_READY" = true ]; then - attempt_auto_registration + if smoke_test_pve_token; then + attempt_auto_registration + else + AUTO_REG_SUCCESS=false + fi else AUTO_REG_SUCCESS=false fi diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 02cd9265f..5c0ed51fd 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -956,6 +956,31 @@ func TestRootInstallAutoRegisterRotatesExistingDeterministicToken(t *testing.T) } } +func TestRootInstallAutoRegisterSmokeTestsCreatedTokenBeforeRegistration(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatalf("read root install.sh: %v", err) + } + script := string(content) + + for _, needle := range []string{ + `smoke_test_pve_auto_register_token() {`, + `curl --retry 2 --retry-delay 1 -kfsS -H "Authorization: PVEAPIToken=${token_id}=${token_value}" "${host_url%/}/api2/json/nodes"`, + `AUTO_NODE_REGISTER_ERROR="token smoke check failed"`, + `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`, + } { + if !strings.Contains(script, needle) { + t.Fatalf("install.sh missing Proxmox token smoke-check contract: %s", needle) + } + } + + smokeCallIdx := strings.Index(script, `smoke_test_pve_auto_register_token "$normalized_host_url" "$token_id" "$token_value"`) + registerIdx := strings.Index(script, `curl --retry 3 --retry-delay 2 -fsS -X POST "$pulse_url/api/auto-register" -H "Content-Type: application/json" -d "$register_payload"`) + if smokeCallIdx < 0 || registerIdx < 0 || smokeCallIdx > registerIdx { + t.Fatalf("expected token smoke check to run before /api/auto-register (smoke=%d register=%d)", smokeCallIdx, registerIdx) + } +} + func TestRootInstallDeployAgentScriptsDeploysSignatureSidecars(t *testing.T) { extractDir := t.TempDir() installDir := t.TempDir()