mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
ffd50a0052
commit
3dd2ecd198
12 changed files with 457 additions and 41 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
install.sh
26
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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:"`) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue