Add Proxmox token smoke diagnostics

Refs #1476
This commit is contained in:
rcourtman 2026-07-07 10:10:16 +01:00
parent ffd50a0052
commit 3dd2ecd198
12 changed files with 457 additions and 41 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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:"`) {

View file

@ -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 {

View file

@ -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()

View file

@ -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")
}
})
}
}

View file

@ -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

View file

@ -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()