diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e1dde63f6..b52191ba4 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1107,6 +1107,13 @@ surface and no new `internal/api/` lifecycle handler. limited to the PVE command-agent path; it must not become the default for Docker / Podman, host-only, PBS-only, or ordinary non-command agents. Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes. + Unix `--update` re-entry must also preserve lifecycle identity for legacy + v5.1.x agents that do not yet have v6 `connection.env` state. When a + running `pulse-agent` process or its systemd unit already carries the Pulse + URL, token, feature flags, agent id, hostname, or trust posture, the shell + installer may recover those values for the update handoff, but the resulting + v6 service must be rendered through the shared exec-argument builder and + migrate the token into the installer-owned `--token-file` path. Approval-gated command execution must expose stable rejection reasons for invalid approval grants so fleet operators can distinguish missing, expired, mismatched, and signature-invalid grants through agent metrics. @@ -1333,7 +1340,7 @@ surface and no new `internal/api/` lifecycle handler. 1. Update this contract when agent lifecycle ownership changes. Routes added under the shared `internal/api/` extension point that are clearly outside lifecycle ownership (for example `POST /api/ai/patrol/preflight`, the `patrol_preflight` snapshot field added to `/api/settings/ai`, the auto-trigger preflight dispatch on settings save, the startup-seed dispatch in `NewAISettingsHandler`, and the cached-preflight integration into the Patrol `tools` readiness check — all owned by ai-runtime) do not extend this subsystem's contract; they live in their owning subsystem. 2. Keep shared API proof routing aligned whenever install, register, or profile payloads change. -3. Update runtime and settings tests in the same slice when lifecycle behavior changes. +3. Update runtime and settings tests in the same slice when lifecycle behavior changes. Shell installer lifecycle changes must keep `scripts/installtests/install_sh_test.go` covering explicit flags, persisted connection state, legacy running-process/service recovery, and secure token-file service argument rendering for update re-entry. 4. Keep host-agent test hooks, command-client factories, and timing overrides instance-scoped under `internal/hostagent/agent.go`; lifecycle-owned registration and update paths must not depend on package-global mutable test @@ -3775,6 +3782,11 @@ installer-owned helper path: `scripts/install.sh` may not write the state file one way and then recover it through a separate field-by-field inline parser, because lifecycle ownership requires one canonical reader/writer for persisted install identity and trust metadata. +When persisted state is absent or partial during update, legacy running-process +or service-unit recovery is a fallback into that same lifecycle continuity +model, not a separate source of truth: it may only seed the installer-owned +state for the upgrade and must not keep raw token arguments in the installed v6 +service command. That same lifecycle ownership must cover service control too: the installer may still choose different platform adapters, but stop/restart semantics for the managed agent must route through shared installer helpers instead of being diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0e47d877c..fcd82399f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -270,6 +270,12 @@ TLS floor in the dynamic config. settings, insecure flag, and persisted agent id from the local installed agent state, must fail closed when no existing installation or connection state is present, and must refuse to silently become a new install command. + That recovery must not depend only on a v6 `connection.env`: v5.1.x agents + that predate persisted connection state may recover the existing URL, token, + feature flags, identity, and trust posture from the running `pulse-agent` + process or its systemd service definition, then persist the upgraded runtime + back through the v6 token-file service-argument path rather than keeping the + raw token in process arguments. The shell installer must disclose `--enable-commands` as Pulse command execution, disabled by default, and must name both Patrol actions and Proxmox LXC Docker inventory as the operator-visible reasons to enable it. @@ -363,6 +369,14 @@ TLS floor in the dynamic config. deploy them for LXC / systemd installs too. The original gap (sidecars never deployed on LXC, so the endpoint proxied the SERVER installer) shipped as the rc.6 agent-wizard regression (issue #1470). + The agent installer update path must also cover legacy v5 process-state + recovery as a first-class installability behavior: a tokenless + `--update --url ` command copied from the v6 UI must be able to + upgrade an already-running v5.1.x `pulse-agent` that was launched with + `--url`, `--token`, feature flags, and identity arguments even when + `connection.env` is missing or incomplete. The upgraded service must be + rendered through the shared exec-argument builder and use the secure + `--token-file` runtime path. 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 @@ -688,7 +702,7 @@ TLS floor in the dynamic config. 1. Update this contract when canonical deployment or installer entry points move 2. Keep deployment runtime and shared API proof routing aligned in `registry.json` -3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change +3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery, including the rule that upgraded service args use `--token-file` instead of raw `--token`. 4. Keep stable and prerelease packet lineage explicit when `docs/releases/` or `VERSION` changes: preserve already-shipped RC packets under dedicated historical filenames before reusing canonical stable names, keep diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx b/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx index f8d213751..f90e05853 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.links.test.tsx @@ -189,6 +189,10 @@ vi.mock('@/stores/aiIntelligence', () => ({ })); describe('FindingsPanel resource links', () => { + const expectCollapsedFinding = (title: string) => { + expect(screen.getByRole('button', { name: `View details for ${title}` })).toBeInTheDocument(); + }; + beforeEach(() => { mockState.findings = [...mockState.initialFindings]; mockState.patrolFindings = [...mockState.initialPatrolFindings]; @@ -355,7 +359,7 @@ describe('FindingsPanel resource links', () => { await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled()); expect(screen.getByText('warning')).toBeInTheDocument(); - expect(screen.getByText('Database latency spike')).toBeInTheDocument(); + expectCollapsedFinding('Database latency spike'); expect(screen.queryByText('detected')).not.toBeInTheDocument(); expect(screen.queryByText('Review finding')).not.toBeInTheDocument(); }); @@ -400,8 +404,8 @@ describe('FindingsPanel resource links', () => { await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled()); - expect(screen.getByText('Database latency spike')).toBeInTheDocument(); - expect(screen.getByText('Web service restart loop')).toBeInTheDocument(); + expectCollapsedFinding('Database latency spike'); + expectCollapsedFinding('Web service restart loop'); expect(screen.queryByText('Patrol investigating')).not.toBeInTheDocument(); expect(screen.queryByText('Verify outcome')).not.toBeInTheDocument(); expect(screen.queryByText('Review finding')).not.toBeInTheDocument(); @@ -511,10 +515,8 @@ describe('FindingsPanel resource links', () => { expect(screen.getByText('critical')).toBeInTheDocument(); expect(screen.getByText('warning')).toBeInTheDocument(); - expect( - screen.getByText('Unraid array running without parity protection while at 86% capacity'), - ).toBeInTheDocument(); - expect(screen.getByText('Storage pool Tower Array at 85.9% usage')).toBeInTheDocument(); + expectCollapsedFinding('Unraid array running without parity protection while at 86% capacity'); + expectCollapsedFinding('Storage pool Tower Array at 85.9% usage'); expect(screen.queryByText('detected')).not.toBeInTheDocument(); expect(screen.queryByText('Review finding')).not.toBeInTheDocument(); @@ -536,17 +538,17 @@ describe('FindingsPanel resource links', () => { ), ).toBeInTheDocument(); expect( - screen.getByText( + screen.getAllByText( 'Verify the parity check is progressing and let it complete before making array changes.', ), - ).toBeInTheDocument(); + ).toHaveLength(2); expect(screen.getByText('Recommended next step:')).toBeInTheDocument(); expect(screen.getByText('What Pulse checked:')).toBeInTheDocument(); expect( - screen.getByText( + screen.getAllByText( 'Checked array status via Unraid API: parity_status=check, capacity=85.9%, disk2=95.8%.', ), - ).toBeInTheDocument(); + ).toHaveLength(2); }); it('can render Patrol findings from the direct Patrol source', async () => { @@ -555,7 +557,7 @@ describe('FindingsPanel resource links', () => { await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled()); expect(mockState.loadFindings).not.toHaveBeenCalled(); - expect(screen.getByText('Provider connection issue')).toBeInTheDocument(); + expectCollapsedFinding('Provider connection issue'); }); it('renders Patrol findings while the unified findings request is still loading', async () => { @@ -567,7 +569,7 @@ describe('FindingsPanel resource links', () => { await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled()); expect(screen.queryByText('Loading findings...')).not.toBeInTheDocument(); - expect(screen.getByText('Provider connection issue')).toBeInTheDocument(); + expectCollapsedFinding('Provider connection issue'); }); it('keeps loaded Patrol findings visible during a Patrol refresh', async () => { @@ -578,7 +580,7 @@ describe('FindingsPanel resource links', () => { await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled()); expect(screen.queryByText('Loading findings...')).not.toBeInTheDocument(); - expect(screen.getByText('Provider connection issue')).toBeInTheDocument(); + expectCollapsedFinding('Provider connection issue'); }); it('renders a selected Patrol run empty snapshot while Patrol findings are loading', async () => { diff --git a/frontend-modern/src/stores/__tests__/systemSettings.test.ts b/frontend-modern/src/stores/__tests__/systemSettings.test.ts index 79538a53b..0278de503 100644 --- a/frontend-modern/src/stores/__tests__/systemSettings.test.ts +++ b/frontend-modern/src/stores/__tests__/systemSettings.test.ts @@ -81,7 +81,7 @@ describe('systemSettings store', () => { expect(privacyDoc).not.toContain('reduceProUpsellNoise'); expect(privacyDoc).toContain('Every field is listed below with the reason it exists'); expect(privacyDoc).toContain('rows older than **90 days** are purged automatically'); - expect(privacyDoc).toContain('uses client IP addresses transiently for abuse/rate limiting'); + expect(privacyDoc).toContain('uses request IP addresses transiently for abuse/rate limiting'); expect(privacyDoc).toContain('Reset ID'); expect(privacyDoc).toContain('Pulse Intelligence Patrol control completed operations loop 30d'); expect(privacyDoc).toContain('Pulse Intelligence Patrol control paid resolved operations loop 30d'); diff --git a/scripts/install.sh b/scripts/install.sh index 3ffcacda8..dbc765bdd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -118,6 +118,8 @@ APPLIED_SERVICE_ENV_KEYS="|" DOCKER_EXPLICIT="false" KUBERNETES_EXPLICIT="false" PROXMOX_EXPLICIT="false" +HOST_EXPLICIT="false" +INTERVAL_EXPLICIT="false" # --- Helper Functions --- log_info() { @@ -1511,6 +1513,334 @@ recover_connection_state() { fi } +strip_recovered_arg_quotes() { + local value="$1" + + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + + printf '%s\n' "$value" +} + +apply_recovered_agent_arg_value() { + local key="$1" + local value="$2" + + value=$(strip_recovered_arg_quotes "$value") + + case "$key" in + url|pulse-url) + if [[ -z "$PULSE_URL" ]]; then PULSE_URL="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + token) + if [[ -z "$PULSE_TOKEN" ]]; then PULSE_TOKEN="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + token-file) + if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then + PULSE_TOKEN=$(cat "$value") + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + interval) + if [[ "$INTERVAL_EXPLICIT" != "true" && -n "$value" ]]; then INTERVAL="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + agent-id) + if [[ -z "$AGENT_ID" ]]; then AGENT_ID="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + hostname) + if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + cacert) + if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + health-addr) + if [[ "$HEALTH_ADDR_SET" != "true" ]]; then + HEALTH_ADDR="$value" + HEALTH_ADDR_SET="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + state-dir) + if [[ -n "$value" && "$STATE_DIR" == "/var/lib/pulse-agent" ]]; then STATE_DIR="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + kubeconfig) + if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then + KUBECONFIG_PATH="$value" + KUBERNETES_EXPLICIT="true" + ENABLE_KUBERNETES="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + proxmox-type) + if [[ -z "$PROXMOX_TYPE" ]]; then PROXMOX_TYPE="$value"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + disk-exclude) + DISK_EXCLUDES+=("$value") + RECOVERED_AGENT_ARG_STATE="true" + ;; + esac +} + +recover_connection_state_from_arg_stream() { + local arg="" + local pending_key="" + local key="" + local value="" + + RECOVERED_AGENT_ARG_STATE="false" + + while IFS= read -r arg; do + if [[ -n "$pending_key" ]]; then + apply_recovered_agent_arg_value "$pending_key" "$arg" + pending_key="" + continue + fi + + case "$arg" in + --url|--pulse-url|--token|--token-file|--interval|--agent-id|--hostname|--cacert|--health-addr|--state-dir|--kubeconfig|--proxmox-type|--disk-exclude) + pending_key="${arg#--}" + ;; + --url=*|--pulse-url=*|--token=*|--token-file=*|--interval=*|--agent-id=*|--hostname=*|--cacert=*|--health-addr=*|--state-dir=*|--kubeconfig=*|--proxmox-type=*|--disk-exclude=*) + key="${arg%%=*}" + value="${arg#*=}" + apply_recovered_agent_arg_value "${key#--}" "$value" + ;; + --enable-host|--enable-host=true) + if [[ "$HOST_EXPLICIT" != "true" ]]; then ENABLE_HOST="true"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-host=false|--disable-host) + if [[ "$HOST_EXPLICIT" != "true" ]]; then ENABLE_HOST="false"; fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-docker|--enable-docker=true) + if [[ "$DOCKER_EXPLICIT" != "true" ]]; then + ENABLE_DOCKER="true" + DOCKER_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-docker=false|--disable-docker) + if [[ "$DOCKER_EXPLICIT" != "true" ]]; then + ENABLE_DOCKER="false" + DOCKER_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-kubernetes|--enable-kubernetes=true) + if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then + ENABLE_KUBERNETES="true" + KUBERNETES_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-kubernetes=false|--disable-kubernetes) + if [[ "$KUBERNETES_EXPLICIT" != "true" ]]; then + ENABLE_KUBERNETES="false" + KUBERNETES_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-proxmox|--enable-proxmox=true) + if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then + ENABLE_PROXMOX="true" + PROXMOX_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-proxmox=false|--disable-proxmox) + if [[ "$PROXMOX_EXPLICIT" != "true" ]]; then + ENABLE_PROXMOX="false" + PROXMOX_EXPLICIT="true" + fi + RECOVERED_AGENT_ARG_STATE="true" + ;; + --insecure) + INSECURE="true" + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enable-commands) + ENABLE_COMMANDS="true" + RECOVERED_AGENT_ARG_STATE="true" + ;; + --enroll) + ENROLL="true" + RECOVERED_AGENT_ARG_STATE="true" + ;; + --kube-include-all-pods) + KUBE_INCLUDE_ALL_PODS="true" + RECOVERED_AGENT_ARG_STATE="true" + ;; + --kube-include-all-deployments) + KUBE_INCLUDE_ALL_DEPLOYMENTS="true" + RECOVERED_AGENT_ARG_STATE="true" + ;; + esac + done + + [[ "$RECOVERED_AGENT_ARG_STATE" == "true" && -n "$PULSE_URL" ]] +} + +recover_connection_state_from_env_stream() { + local env_line="" + local value="" + + RECOVERED_AGENT_ENV_STATE="false" + + while IFS= read -r env_line; do + case "$env_line" in + PULSE_URL=*|PULSE_AGENT_URL=*|PULSE_AGENT_CONNECT_URL=*) + value="${env_line#*=}" + if [[ -z "$PULSE_URL" ]]; then PULSE_URL="$value"; fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_TOKEN=*) + value="${env_line#*=}" + if [[ -z "$PULSE_TOKEN" ]]; then PULSE_TOKEN="$value"; fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_TOKEN_FILE=*) + value="${env_line#*=}" + if [[ -z "$PULSE_TOKEN" && -n "$value" && -f "$value" ]]; then + PULSE_TOKEN=$(cat "$value") + fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_AGENT_ID=*) + value="${env_line#*=}" + if [[ -z "$AGENT_ID" ]]; then AGENT_ID="$value"; fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_HOSTNAME=*) + value="${env_line#*=}" + if [[ -z "$HOSTNAME_OVERRIDE" ]]; then HOSTNAME_OVERRIDE="$value"; fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_INSECURE_SKIP_VERIFY=true) + INSECURE="true" + RECOVERED_AGENT_ENV_STATE="true" + ;; + PULSE_CACERT=*) + value="${env_line#*=}" + if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi + RECOVERED_AGENT_ENV_STATE="true" + ;; + esac + done + + [[ "$RECOVERED_AGENT_ENV_STATE" == "true" && -n "$PULSE_URL" ]] +} + +collect_running_agent_pids() { + local path="" + local pid="" + + if command -v pgrep >/dev/null 2>&1; then + { + pgrep -x "$BINARY_NAME" 2>/dev/null || true + pgrep -f "/${BINARY_NAME}( |$)" 2>/dev/null || true + } | awk '!seen[$0]++' + return 0 + fi + + for path in /proc/[0-9]*/cmdline; do + [[ -r "$path" ]] || continue + if tr '\0' '\n' < "$path" 2>/dev/null | grep -qx ".*/${BINARY_NAME}"; then + pid="${path%/cmdline}" + printf '%s\n' "${pid##*/}" + fi + done +} + +recover_connection_state_from_running_agent() { + local pid="" + local recovered="false" + + [[ -d /proc ]] || return 1 + + while IFS= read -r pid; do + [[ -n "$pid" && "$pid" != "$$" ]] || continue + recovered="false" + + if [[ -r "/proc/${pid}/cmdline" ]]; then + if recover_connection_state_from_arg_stream < <(tr '\0' '\n' < "/proc/${pid}/cmdline" 2>/dev/null); then + recovered="true" + fi + fi + if [[ -r "/proc/${pid}/environ" ]]; then + if recover_connection_state_from_env_stream < <(tr '\0' '\n' < "/proc/${pid}/environ" 2>/dev/null); then + recovered="true" + fi + fi + if [[ "$recovered" == "true" && -n "$PULSE_URL" ]]; then + return 0 + fi + done < <(collect_running_agent_pids) + + return 1 +} + +recover_connection_state_from_systemd_unit() { + local unit_path="" + local candidate="" + local line="" + local payload="" + local recovered="false" + + if command -v systemctl >/dev/null 2>&1; then + unit_path=$(systemctl show -p FragmentPath --value "$AGENT_NAME" 2>/dev/null || true) + fi + + for candidate in "$unit_path" "/etc/systemd/system/${AGENT_NAME}.service" "/lib/systemd/system/${AGENT_NAME}.service" "/usr/lib/systemd/system/${AGENT_NAME}.service"; do + [[ -n "$candidate" && -f "$candidate" ]] || continue + recovered="false" + while IFS= read -r line; do + case "$line" in + ExecStart=*) + payload="${line#ExecStart=}" + if recover_connection_state_from_arg_stream < <(printf '%s\n' "$payload" | tr ' ' '\n'); then + recovered="true" + fi + ;; + Environment=*) + payload="${line#Environment=}" + if recover_connection_state_from_env_stream < <(printf '%s\n' "$payload" | tr ' ' '\n' | sed 's/^"//; s/"$//'); then + recovered="true" + fi + ;; + esac + done < "$candidate" + if [[ "$recovered" == "true" && -n "$PULSE_URL" ]]; then + return 0 + fi + done + + return 1 +} + +recover_connection_state_from_existing_agent() { + if recover_connection_state_from_running_agent; then + log_info "Recovered connection details from the running Pulse Agent process." + return 0 + fi + if recover_connection_state_from_systemd_unit; then + log_info "Recovered connection details from the existing Pulse Agent service." + return 0 + fi + + return 1 +} + find_connection_state_file() { local conn_env="" local qnap_state_dir="" @@ -1600,11 +1930,11 @@ while [[ $# -gt 0 ]]; do --help|-h) show_help; exit 0 ;; --url) PULSE_URL="$2"; shift 2 ;; --token) PULSE_TOKEN="$2"; shift 2 ;; - --interval) INTERVAL="$2"; shift 2 ;; - --enable-host) ENABLE_HOST="true"; shift ;; - --enable-host=true) ENABLE_HOST="true"; shift ;; - --enable-host=false) ENABLE_HOST="false"; shift ;; - --disable-host) ENABLE_HOST="false"; shift ;; + --interval) INTERVAL="$2"; INTERVAL_EXPLICIT="true"; shift 2 ;; + --enable-host) ENABLE_HOST="true"; HOST_EXPLICIT="true"; shift ;; + --enable-host=true) ENABLE_HOST="true"; HOST_EXPLICIT="true"; shift ;; + --enable-host=false) ENABLE_HOST="false"; HOST_EXPLICIT="true"; shift ;; + --disable-host) ENABLE_HOST="false"; HOST_EXPLICIT="true"; shift ;; --enable-docker) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;; --enable-docker=true) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;; --enable-docker=false) ENABLE_DOCKER="false"; DOCKER_EXPLICIT="true"; shift ;; @@ -1684,6 +2014,12 @@ if [[ "$UPDATE_ONLY" == "true" ]]; then if [[ -n "$update_conn_env" ]]; then log_info "Recovering connection details from ${update_conn_env}..." recover_connection_state "$update_conn_env" + fi + if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then + recover_connection_state_from_existing_agent || true + fi + if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then + : elif [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then fail "No existing Pulse Agent connection state found. Use the install command instead." "$EXIT_MISSING_ARGS" fi diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 52dadcd74..0d4b97cf8 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -711,7 +711,7 @@ func TestHelmChartDoesNotPublishRetiredExplorePrepassMonitoring(t *testing.T) { } } -func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *testing.T) { +func TestDeployDemoWorkflowFailsClosedForStableAndVerifiesFrontendParity(t *testing.T) { workflowBytes, err := os.ReadFile(repoFile(".github", "workflows", "deploy-demo-server.yml")) if err != nil { t.Fatalf("read deploy-demo-server workflow: %v", err) @@ -723,10 +723,13 @@ func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *tes `DEMO_LOCAL_BASE_URL: ${{ vars.DEMO_LOCAL_BASE_URL }}`, `[ -n "$DEMO_EXPECTED_HOSTNAME" ] || { echo "::error::DEMO_EXPECTED_HOSTNAME is required in the selected demo environment."; exit 1; }`, `[ -n "$DEMO_LOCAL_BASE_URL" ] || { echo "::error::DEMO_LOCAL_BASE_URL is required in the selected demo environment."; exit 1; }`, + `ENVIRONMENT_NAME="demo-stable"`, + `options:`, + ` - stable`, `Capture expected frontend entry asset`, `Verify target host identity`, - `SERVICE_NAME="pulse-v6-preview"`, - `Preview demo deployments must not target the stable pulse service.`, + `SERVICE_NAME="pulse"`, + `Unsupported demo target: ${TARGET}`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, `Verify frontend parity`, `Verify public browser smoke`, @@ -738,7 +741,12 @@ func TestDeployDemoWorkflowFailsClosedForPreviewAndVerifiesFrontendParity(t *tes } for _, needle := range required { if !strings.Contains(workflow, needle) { - t.Fatalf("deploy-demo-server workflow missing preview isolation or frontend parity proof: %s", needle) + t.Fatalf("deploy-demo-server workflow missing stable isolation or frontend parity proof: %s", needle) + } + } + for _, forbidden := range []string{`pulse-v6-preview`, `preview-v6`, `demo-preview-v6`} { + if strings.Contains(workflow, forbidden) { + t.Fatalf("deploy-demo-server workflow must not retain retired v6 preview target %s", forbidden) } } } diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 2a2c82fa2..d55ae3ee3 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -104,8 +104,8 @@ func TestInstallSHAcceptsLegacyBooleanFlagValues(t *testing.T) { script := string(content) required := []string{ - `--enable-host=true) ENABLE_HOST="true"; shift ;;`, - `--enable-host=false) ENABLE_HOST="false"; shift ;;`, + `--enable-host=true) ENABLE_HOST="true"; HOST_EXPLICIT="true"; shift ;;`, + `--enable-host=false) ENABLE_HOST="false"; HOST_EXPLICIT="true"; shift ;;`, `--enable-docker=true) ENABLE_DOCKER="true"; DOCKER_EXPLICIT="true"; shift ;;`, `--enable-docker=false) ENABLE_DOCKER="false"; DOCKER_EXPLICIT="true"; shift ;;`, `--enable-kubernetes=true) ENABLE_KUBERNETES="true"; KUBERNETES_EXPLICIT="true"; shift ;;`, @@ -640,6 +640,13 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) { `if [[ "$UPDATE_ONLY" == "true" ]]; then`, `update_conn_env=$(find_connection_state_file || true)`, `recover_connection_state "$update_conn_env"`, + `recover_connection_state_from_existing_agent() {`, + `recover_connection_state_from_running_agent`, + `recover_connection_state_from_systemd_unit`, + `recover_connection_state_from_arg_stream`, + `recover_connection_state_from_env_stream`, + `recover_connection_state_from_existing_agent || true`, + `if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then`, `recover_agent_id_from_state_file() {`, `AGENT_ID=$(recover_agent_id_from_state_file || true)`, `No existing Pulse Agent connection state found. Use the install command instead.`, @@ -653,6 +660,89 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) { } } +func TestInstallSHRecoversV5ProcessArgsForSavedStateUpdate(t *testing.T) { + script := ` + fail() { echo "FAIL:$1"; exit 99; } + PULSE_URL="" + PULSE_TOKEN="" + INTERVAL="30s" + INTERVAL_EXPLICIT="false" + ENABLE_HOST="true" + HOST_EXPLICIT="false" + ENABLE_DOCKER="" + DOCKER_EXPLICIT="false" + ENABLE_KUBERNETES="" + KUBERNETES_EXPLICIT="false" + KUBECONFIG_PATH="" + ENABLE_PROXMOX="" + PROXMOX_EXPLICIT="false" + PROXMOX_TYPE="" + INSECURE="false" + ENABLE_COMMANDS="false" + ENROLL="false" + HEALTH_ADDR="" + HEALTH_ADDR_SET="false" + AGENT_ID="" + HOSTNAME_OVERRIDE="" + STATE_DIR="/var/lib/pulse-agent" + CURL_CA_BUNDLE="" + KUBE_INCLUDE_ALL_PODS="false" + KUBE_INCLUDE_ALL_DEPLOYMENTS="false" + DISK_EXCLUDES=() + RUNTIME_TOKEN_FILE="/var/lib/pulse-agent/token" +` + extractInstallShellFunction(t, "strip_recovered_arg_quotes") + ` +` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` +` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` +` + extractInstallShellFunction(t, "build_exec_arg_items") + ` +` + extractInstallShellFunction(t, "join_exec_arg_items") + ` +` + extractInstallShellFunction(t, "build_exec_args") + ` + recover_connection_state_from_arg_stream <<'ARGS' +/usr/local/bin/pulse-agent +--url +http://192.168.2.96:7655 +--token +deadbeef +--interval +30s +--enable-host +--enable-docker +--insecure +--agent-id +agent-123 +--hostname +pve-one +ARGS + build_exec_args + printf 'URL=%s\nTOKEN=%s\nDOCKER=%s\nDOCKER_EXPLICIT=%s\nINSECURE=%s\nAGENT_ID=%s\nHOSTNAME=%s\nEXEC_ARGS=%s\n' \ + "$PULSE_URL" "$PULSE_TOKEN" "$ENABLE_DOCKER" "$DOCKER_EXPLICIT" "$INSECURE" "$AGENT_ID" "$HOSTNAME_OVERRIDE" "$EXEC_ARGS" + ` + + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + got := string(out) + required := []string{ + "URL=http://192.168.2.96:7655", + "TOKEN=deadbeef", + "DOCKER=true", + "DOCKER_EXPLICIT=true", + "INSECURE=true", + "AGENT_ID=agent-123", + "HOSTNAME=pve-one", + "--token-file /var/lib/pulse-agent/token", + "--enable-docker", + } + for _, needle := range required { + if !strings.Contains(got, needle) { + t.Fatalf("recovered update state missing %q:\n%s", needle, got) + } + } + if strings.Contains(got, "--token deadbeef") { + t.Fatalf("recovered service args leaked raw token:\n%s", got) + } +} + func TestInstallSHUsesCanonicalServiceLifecycleHelpers(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.sh")) if err != nil {