Fix legacy agent update state recovery

Refs #1515
This commit is contained in:
rcourtman 2026-07-04 17:22:29 +01:00
parent de0c5ff5c1
commit 64d58fd161
7 changed files with 490 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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