mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
de0c5ff5c1
commit
64d58fd161
7 changed files with 490 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue