mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Repair rejected agent credentials safely
This commit is contained in:
@@ -701,6 +701,23 @@ installer path. The `agentDoctor` route key is canonical; the older
|
||||
Doctor must not replace the canonical `/api/connections` fleet projection used
|
||||
by Infrastructure.
|
||||
|
||||
Authentication repair is a distinct lifecycle handoff, not a successful
|
||||
upgrade. When a reported host still names a token record that is absent from
|
||||
the server inventory or has expired, Agent Doctor must raise a critical
|
||||
credential reason and may offer `repair_authentication` only for a recognized,
|
||||
unambiguous local runtime. The copied Unix repair command mints a fresh scoped
|
||||
agent credential and transports it through an ephemeral `--token-file`; an
|
||||
ordinary version-only Unix update stays tokenless and recovers the installed
|
||||
credential from installer-owned state. If two different host agent IDs report
|
||||
the same non-empty machine ID and equivalent hostname, both installations stay
|
||||
visible as distinct fleet rows with peer identity/version evidence. Pulse must
|
||||
not collapse them into one apparent agent or choose one generic local service:
|
||||
authentication and upgrade handoffs fail closed until the duplicate
|
||||
installation is resolved or an installation-specific path exists. Finally,
|
||||
local process health cannot override server authentication: a definitive
|
||||
registration 401/403 makes `scripts/install.sh` complete with the non-zero
|
||||
`auth_rejected` result instead of printing installation or upgrade success.
|
||||
|
||||
Agent lifecycle and fleet-operation surfaces may consume
|
||||
`POST /api/actions/plan` for resource capability planning, but the action plan
|
||||
contract remains API-owned through `internal/api/actions.go`,
|
||||
|
||||
@@ -6180,6 +6180,21 @@ must tolerate absent optional fields and preserve `/api/connections` fallback
|
||||
rows. Repair objects describe whether an existing local handoff is supported
|
||||
and for which normalized platform; they are not executable commands or
|
||||
authorization grants.
|
||||
The canonical connections projection must validate a host's non-empty
|
||||
`TokenID` against the current API-token inventory. Missing records project
|
||||
`fleet.credentialStatus="invalid"` and credential-health
|
||||
`status/rotation="invalid"`; expired records project invalid top-level status
|
||||
with expired health and rotation. Agent Doctor preserves those ledger facts
|
||||
even when its structured snapshot lags. Structured diagnostics add the stable
|
||||
`agent_credential_missing`, `agent_credential_expired`, and
|
||||
`duplicate_host_agent_installation` reason codes plus the
|
||||
`repair_authentication` handoff. The browser may render a fresh scoped
|
||||
token-file repair only when that action is supported. A duplicate-installation
|
||||
reason always blocks generic repair command rendering, while retaining each
|
||||
connection row and its peer evidence so UI composition never selects one stale
|
||||
identity as the machine's sole agent. When the token inventory is deliberately
|
||||
unavailable, including synthetic mock-ledger projection, the API preserves the
|
||||
prior unknown-compatible credential state instead of inventing invalidity.
|
||||
That same shared infrastructure-settings boundary also owns install-profile
|
||||
semantics surfaced by
|
||||
`frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`:
|
||||
|
||||
@@ -3058,6 +3058,15 @@ gets skipped the time it is real. Token-bearing
|
||||
copy-paste commands must pass credentials through ephemeral `--token-file`
|
||||
transport and leave the installed service configured with the persistent
|
||||
runtime token file, never a raw `--token` process argument.
|
||||
The same rule governs credential repair after a server restore, token
|
||||
revocation, or expiry: the Unix repair handoff must run the normal installer
|
||||
preflight, pass the fresh scoped credential through a temporary mode-0600 token
|
||||
file, and invoke update mode without exposing the token in the agent service
|
||||
arguments. Installer completion is authenticated completion, not merely local
|
||||
process health. When the post-start registration lookup returns 401 or 403,
|
||||
`scripts/install.sh` must emit the structured `auth_rejected` completion and
|
||||
exit 18; it must not print the normal install/upgrade success message or report
|
||||
the Proxmox registration outcome as though the agent were enrolled.
|
||||
That Windows installer-owned state must also be cleared after successful
|
||||
PowerShell uninstall, so a removed installation does not leave stale ProgramData
|
||||
identity or transport continuity behind for later lifecycle commands.
|
||||
|
||||
@@ -2594,6 +2594,20 @@ avoids a duplicated distro allowlist, so long-tail distributions such as
|
||||
Mageia receive the same Linux repair handoff as Ubuntu or Debian. Unknown
|
||||
updater states remain explicit warnings, and unverified FreeBSD/pfSense
|
||||
installer state still fails closed for upgrade-command support.
|
||||
Credential truth is derived from the current server token inventory, not the
|
||||
last successful heartbeat alone. A live host whose reported `TokenID` no
|
||||
longer resolves emits `agent_credential_missing`; a resolved but expired record
|
||||
emits `agent_credential_expired`; both are critical and expose a bounded
|
||||
authentication-repair handoff only when the runtime family is safely known.
|
||||
Monitoring also compares live host-agent generations without merging them: two
|
||||
different host IDs with the same non-empty machine ID and equivalent hostname
|
||||
each receive `duplicate_host_agent_installation` plus bounded peer evidence.
|
||||
That reason disables generic upgrade and authentication handoffs, because the
|
||||
server cannot infer which co-installed local service an operator's command
|
||||
would mutate. Distinct rows are retained so Agent Doctor does not disguise two
|
||||
installations as one healthy machine. Mock mode has no authoritative token
|
||||
inventory for its synthetic hosts and therefore must not turn fixture token IDs
|
||||
into missing-credential incidents.
|
||||
`internal/fleethealth/agent_test.go` and
|
||||
`internal/monitoring/agent_fleet_doctor_test.go` are the focused runtime proofs.
|
||||
|
||||
|
||||
@@ -183,6 +183,16 @@ states do not change the underlying storage evidence or active lifecycle.
|
||||
|
||||
## Extension Points
|
||||
|
||||
Agent-token validation added to `internal/api/connections_aggregator.go` and
|
||||
its runtime input assembly in `internal/api/connections_alerts.go` is
|
||||
fleet-governance evidence only. A missing, revoked, or expired host-agent
|
||||
credential may mark that agent connection invalid and drive a local
|
||||
authentication-repair handoff, but storage/recovery consumers must not
|
||||
reinterpret that credential state as backup failure, protection drift,
|
||||
recovery-point loss, restore readiness, datastore health, or provider
|
||||
identity. Token inventory that is unavailable or deliberately synthetic stays
|
||||
unknown rather than fabricating storage or agent incidents.
|
||||
|
||||
Docker / Podman report ingress under `internal/api/docker_agents.go` is outside
|
||||
storage/recovery ownership even though storage/recovery routes share the broad
|
||||
`internal/api/` extension boundary. Encoded/decoded report ceilings,
|
||||
|
||||
@@ -1,50 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "7d95a707bcedd82c48158d48e1d203d2c8ea9d07",
|
||||
"verified_at": "2026-08-08T15:41:56Z",
|
||||
"base_sha": "4e1d2f6d5dc0819cba4cd3549f82bd87c2e99296",
|
||||
"verified_at": "2026-08-08T23:45:58Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/api/settings.ts",
|
||||
"frontend-modern/src/components/BusinessEstateCard.tsx",
|
||||
"frontend-modern/src/components/Settings/ProLicensePanel.tsx",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
|
||||
"frontend-modern/src/components/Settings/settingsNavVisibility.ts",
|
||||
"frontend-modern/src/components/Settings/settingsNavigationModel.ts",
|
||||
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
|
||||
"frontend-modern/src/components/Settings/useSettingsAccess.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
|
||||
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts",
|
||||
"frontend-modern/src/stores/sessionCapabilities.ts",
|
||||
"frontend-modern/src/types/config.ts",
|
||||
"frontend-modern/src/utils/pricingHandoff.ts"
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/App.tsx": "cf12ab5e6b1e96d1bdf7da889db49b88f4c3161fb60fa9b879400039b361f84c",
|
||||
"frontend-modern/src/api/settings.ts": "5347422cc84eb13f212e196cddaa45515446b445858ff95f9d1a6ba6810db6f7",
|
||||
"frontend-modern/src/components/BusinessEstateCard.tsx": "deleted",
|
||||
"frontend-modern/src/components/Settings/ProLicensePanel.tsx": "be6955b43cc09cd2bca41cdf3a93c2ebe2c6c95c8a893b777df6d9bae7d35fd1",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "a95ed0a83af7ba54755928b2fb4955481780240c03351f6b850f3b31d696dc5e",
|
||||
"frontend-modern/src/components/Settings/settingsNavVisibility.ts": "05407c809e71724694ba39a01288d3580bbe3c35ab7c5dee502ed0ab31364a13",
|
||||
"frontend-modern/src/components/Settings/settingsNavigationModel.ts": "94f00fe53960b3d7fda42ad7b226197e9fddf5b055cdda80615c75274f464ea0",
|
||||
"frontend-modern/src/components/Settings/useProLicensePanelState.ts": "7559445debf9afdd86d2e3c5f15a22e81f3eacc4e5bd185c54fe1fc586933f41",
|
||||
"frontend-modern/src/components/Settings/useSettingsAccess.ts": "42c586bdc3a5f91fdcd8c120bf65e4bd7105d5cc948defc6784ccf389ff631b4",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "de74125176e6d7421cec5717c963cd8e992f0ef0f11bf9304bb7b75c6f844658",
|
||||
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "7f68482c556ef0164770ac81156a38c6dd3ef600bfa567240cf5ffaf0b517784",
|
||||
"frontend-modern/src/stores/sessionCapabilities.ts": "8e7557152025a5bdd99a8072b6283c4d3ec3c21ebbeab4f4464ee10308fa9e60",
|
||||
"frontend-modern/src/types/config.ts": "86778a683f7f259bb3eb0b47586ac177e82a953955538868cec25010585fd5ec",
|
||||
"frontend-modern/src/utils/pricingHandoff.ts": "d7b863c45a6f8d8a991e8aad99e03869aa50bb3ad525c9ab22a445c1607b4d4d"
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6e4afa78ae0cfda3344573174893b80a3930d62e1f3bf2ef9c3288331c18b114",
|
||||
"frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "b6fee8ae8f5fcb8f1a29e561b7ec66845e1183f26888541c12dd1a2a0b57d2a2",
|
||||
"frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx": "6abfe2d91ed052f968ed45952792660d13d4dc469926a499b3929e0b4c994510"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/security-roles",
|
||||
"/settings/pulse-intelligence/billing/plan",
|
||||
"/settings/system-general",
|
||||
"/patrol"
|
||||
"/settings/infrastructure/agent-doctor"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
@@ -52,20 +27,15 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Authenticated free self-hosted Roles route with presentationPolicy.hideUpgrade=true, no paid-feature or Plans & Billing navigation, and no trial, upgrade, hosted-handoff, or business-estate prompt.",
|
||||
"Authenticated free self-hosted direct Plans & Billing route with the Community plan visible while its sidebar navigation item remains absent.",
|
||||
"The refreshed responsive Settings shell kept direct content inside the 390-pixel viewport after a desktop-to-narrow resize and preserved the General route after mobile navigation.",
|
||||
"Commercial Roles and direct Plans & Billing states rendered from the final production frontend build at 1280x800 and 390x844 and were inspected from captured Chromium screenshots.",
|
||||
"Authenticated free self-hosted Patrol rendered only the Watch only mode, with no disabled paid-mode controls, Unlock Patrol modes copy, or Plans & Billing action at 1280x800 and 390x844.",
|
||||
"The production Patrol build remained within the 390-pixel viewport with no horizontal overflow, while the mode, run control, settings control, attention filters, and finding rows remained visible and usable."
|
||||
"Authenticated mock Agent Doctor rendered 2 critical, 1 needs-attention, and 29 healthy agents without inventing credential failures from non-authoritative mock token inventory.",
|
||||
"Credential-repair mode rendered a repair-aware banner and required a separate one-use scoped credential for each selected installation when multiple repairs were present.",
|
||||
"The live signed-in Agent Doctor retained duplicate Pi installations as distinct rows with their separate agent versions and identities.",
|
||||
"The Agent Doctor remained within the 390-pixel viewport with no horizontal document overflow."
|
||||
],
|
||||
"interactions": [
|
||||
"Built the production frontend and local Pulse backend from the refreshed isolated candidate, authenticated through the managed Playwright backend, and mocked only the commercial posture, entitlements, runtime capabilities, and security status responses.",
|
||||
"Opened /settings/security-roles and verified default paid navigation and proactive commercial calls to action were absent while the deliberately reached Roles panel remained usable.",
|
||||
"Opened /settings/pulse-intelligence/billing/plan directly and verified the Plans & Billing panel and Community plan rendered without restoring its sidebar navigation item.",
|
||||
"Ran the canonical Settings shell matrix, resized direct Plans & Billing from desktop to 390x844, verified the content remained within the viewport, and navigated the narrow shell to General.",
|
||||
"Captured and inspected full-page screenshots for commercial desktop and narrow states.",
|
||||
"Built and embedded the final production frontend, opened /patrol against the isolated backend, confirmed Watch only was the sole free-session Patrol mode, and inspected captured desktop and narrow screenshots.",
|
||||
"Measured the 390-pixel Patrol document at a 390-pixel scroll width and verified the Run Patrol, Patrol settings, attention-state selector, refresh action, and finding list remained reachable without exposing paid-mode discovery."
|
||||
"Opened the isolated current-code Agent Doctor and verified the final mock fleet summary after the token-inventory authority fix.",
|
||||
"Exercised the multiple-installation credential-repair state and verified the installation selector, separate-token guidance, and per-installation unlock behavior.",
|
||||
"Inspected the live Pi duplicate-installation presentation and confirmed both reporting installations remained independently visible.",
|
||||
"Resized Agent Doctor to 390x844 and measured a 390-pixel document scroll width against a 390-pixel viewport."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
) => {
|
||||
const operations = useInfrastructureOperationsContext();
|
||||
const summary = createMemo(() => summarizeInfrastructureAgentDoctorTargets(props.targets));
|
||||
const anyTargetNeedsUpdate = createMemo(() => props.targets.some((target) => target.needsUpdate));
|
||||
const anyTargetNeedsRepair = createMemo(() =>
|
||||
props.targets.some((target) => target.needsUpdate || target.needsCredentialRepair),
|
||||
);
|
||||
const summaryChips = createMemo(() => {
|
||||
const counts = summary();
|
||||
const order: { status: InfrastructureAgentDoctorStatus; count: number }[] = [
|
||||
@@ -92,30 +94,56 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
const commandTargets = createMemo(() =>
|
||||
props.targets.filter(
|
||||
(target) =>
|
||||
target.needsUpdate &&
|
||||
(target.needsUpdate || target.needsCredentialRepair) &&
|
||||
Boolean(target.connection) &&
|
||||
Boolean(target.commandPlatform) &&
|
||||
!target.commandBlockedReason,
|
||||
),
|
||||
);
|
||||
const tokenGatedTargetCount = createMemo(
|
||||
() =>
|
||||
commandTargets().filter(
|
||||
(target) =>
|
||||
target.connection &&
|
||||
operations.getAgentConnectionUpgradeCommandRequiresToken(
|
||||
target.connection,
|
||||
target.commandPlatform ?? undefined,
|
||||
),
|
||||
).length,
|
||||
const tokenGatedTargets = createMemo(() =>
|
||||
commandTargets().filter(
|
||||
(target) =>
|
||||
target.connection &&
|
||||
operations.getAgentConnectionUpgradeCommandRequiresToken(
|
||||
target.connection,
|
||||
target.commandPlatform ?? undefined,
|
||||
target.needsCredentialRepair,
|
||||
),
|
||||
),
|
||||
);
|
||||
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) =>
|
||||
Boolean(target.connection && target.commandPlatform && !target.commandBlockedReason) &&
|
||||
(!operations.getAgentConnectionUpgradeCommandRequiresToken(
|
||||
const tokenGatedTargetCount = createMemo(() => tokenGatedTargets().length);
|
||||
const [selectedTokenTargetKey, setSelectedTokenTargetKey] = createSignal('');
|
||||
const [tokenMintedForTargetKey, setTokenMintedForTargetKey] = createSignal('');
|
||||
const selectedTokenTarget = createMemo(
|
||||
() =>
|
||||
tokenGatedTargets().find((target) => target.key === selectedTokenTargetKey()) ??
|
||||
tokenGatedTargets()[0],
|
||||
);
|
||||
const commandRequiresToken = (target: InfrastructureAgentDoctorTarget) =>
|
||||
operations.getAgentConnectionUpgradeCommandRequiresToken(
|
||||
target.connection!,
|
||||
target.commandPlatform!,
|
||||
) ||
|
||||
operations.commandsUnlocked());
|
||||
target.needsCredentialRepair,
|
||||
);
|
||||
const commandReadyForTarget = (target: InfrastructureAgentDoctorTarget) => {
|
||||
if (!target.connection || !target.commandPlatform || target.commandBlockedReason) return false;
|
||||
if (!commandRequiresToken(target)) return true;
|
||||
if (!operations.commandsUnlocked()) return false;
|
||||
return tokenGatedTargetCount() === 1 || tokenMintedForTargetKey() === target.key;
|
||||
};
|
||||
|
||||
const generateRepairToken = async () => {
|
||||
const target = selectedTokenTarget();
|
||||
if (!target) return;
|
||||
const previousToken = operations.currentToken();
|
||||
operations.setEnableCommands(Boolean(target.connection?.agentIdentity?.commandsEnabled));
|
||||
await operations.handleGenerateToken();
|
||||
const generatedToken = operations.currentToken();
|
||||
if (generatedToken && generatedToken !== previousToken) {
|
||||
setTokenMintedForTargetKey(target.key);
|
||||
setSelectedTokenTargetKey(target.key);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCommand = async (command: string) => {
|
||||
const success = await copyToClipboard(command);
|
||||
@@ -260,11 +288,11 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Show when={anyTargetNeedsUpdate()}>
|
||||
<Show when={anyTargetNeedsRepair()}>
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-xs leading-5 text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-100">
|
||||
Update commands are host-local: copy one to the affected machine to update its Pulse
|
||||
Agent from this server. They do not update the Pulse server runtime and Pulse does not
|
||||
run them remotely.
|
||||
Update and authentication-repair commands are host-local: copy one to the affected
|
||||
machine to repair its Pulse Agent from this server. They do not update the Pulse server
|
||||
runtime and Pulse does not run them remotely.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -272,7 +300,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
when={
|
||||
tokenGatedTargetCount() > 0 &&
|
||||
operations.requiresToken() &&
|
||||
!operations.commandsUnlocked()
|
||||
(!operations.commandsUnlocked() || tokenGatedTargetCount() > 1)
|
||||
}
|
||||
>
|
||||
<section class="space-y-3 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
@@ -282,18 +310,35 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
</h3>
|
||||
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
|
||||
{tokenGatedTargetCount() === 1
|
||||
? 'One Windows repair needs a scoped install token before Pulse can show its command.'
|
||||
: `${tokenGatedTargetCount()} Windows repairs need a scoped install token before Pulse can show their commands.`}
|
||||
? 'One repair needs a fresh scoped agent credential before Pulse can show its command.'
|
||||
: `${tokenGatedTargetCount()} repairs need separate scoped credentials. Each credential binds to the first agent that uses it; select one installation, generate and run its command, then continue with the next.`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Show when={tokenGatedTargetCount() > 1}>
|
||||
<select
|
||||
value={selectedTokenTarget()?.key}
|
||||
onChange={(event) => setSelectedTokenTargetKey(event.currentTarget.value)}
|
||||
aria-label="Agent installation to repair"
|
||||
class="min-h-10 flex-1 rounded-md border border-blue-200 bg-surface px-3 py-2 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-blue-700 dark:bg-blue-950 dark:focus:ring-blue-900"
|
||||
>
|
||||
<For each={tokenGatedTargets()}>
|
||||
{(target) => (
|
||||
<option value={target.key}>
|
||||
{target.displayName} · {target.connectionId}
|
||||
{target.currentVersion ? ` · ${target.currentVersion}` : ''}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
<input
|
||||
type="text"
|
||||
value={operations.tokenName()}
|
||||
onInput={(event) => operations.setTokenName(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !operations.isGeneratingToken()) {
|
||||
void operations.handleGenerateToken();
|
||||
void generateRepairToken();
|
||||
}
|
||||
}}
|
||||
placeholder="Token name (optional)"
|
||||
@@ -301,11 +346,15 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void operations.handleGenerateToken()}
|
||||
onClick={() => void generateRepairToken()}
|
||||
disabled={operations.isGeneratingToken()}
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{operations.isGeneratingToken() ? 'Generating...' : 'Generate token'}
|
||||
{operations.isGeneratingToken()
|
||||
? 'Generating...'
|
||||
: operations.commandsUnlocked()
|
||||
? 'Generate fresh token'
|
||||
: 'Generate token'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -375,11 +424,14 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
target.connection,
|
||||
target.installFlags,
|
||||
target.commandPlatform,
|
||||
target.needsCredentialRepair,
|
||||
)
|
||||
: '';
|
||||
const otherRepairs = () =>
|
||||
(target.diagnostic?.repairActions ?? []).filter(
|
||||
(action) => action.code !== 'copy_upgrade_command',
|
||||
(action) =>
|
||||
action.code !== 'copy_upgrade_command' &&
|
||||
action.code !== 'repair_authentication',
|
||||
);
|
||||
const uninstallHandoff = () =>
|
||||
getInfrastructureAgentDoctorUninstallHandoff(target);
|
||||
@@ -506,7 +558,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={target.needsUpdate}>
|
||||
<Show when={target.needsUpdate || target.needsCredentialRepair}>
|
||||
<Show
|
||||
when={!target.commandBlockedReason}
|
||||
fallback={
|
||||
@@ -519,15 +571,17 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
when={commandReadyForTarget(target)}
|
||||
fallback={
|
||||
<div class="rounded-md border border-border bg-surface px-3 py-3 text-xs text-muted">
|
||||
Generate a token to unlock this host-local update command.
|
||||
{commandRequiresToken(target) && tokenGatedTargetCount() > 1
|
||||
? 'Select this installation above and generate a separate credential to unlock its host-local repair command.'
|
||||
: 'Generate a token to unlock this host-local repair command.'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative">
|
||||
<CommandCopyButton
|
||||
onClick={() => void copyCommand(command())}
|
||||
title="Copy host-local agent update command"
|
||||
label={`Copy update command for ${target.displayName}`}
|
||||
title="Copy host-local agent repair command"
|
||||
label={`Copy ${target.needsCredentialRepair ? 'authentication repair' : 'update'} command for ${target.displayName}`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<code>{command()}</code>
|
||||
|
||||
+88
@@ -121,6 +121,93 @@ describe('Agent Doctor model', () => {
|
||||
expect(targets[0].commandBlockedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns a missing credential into a token-gated authentication repair', () => {
|
||||
const connection = connectionFixture({
|
||||
agentUpdateAvailable: false,
|
||||
agentVersion: '6.2.0',
|
||||
expectedAgentVersion: '6.2.0',
|
||||
fleet: {
|
||||
versionDrift: 'current',
|
||||
credentialStatus: 'invalid',
|
||||
credentialHealth: {
|
||||
status: 'invalid',
|
||||
kind: 'agent-token',
|
||||
rotation: 'invalid',
|
||||
},
|
||||
} as Connection['fleet'],
|
||||
});
|
||||
const [target] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [
|
||||
diagnosticFixture({
|
||||
status: 'critical',
|
||||
version: '6.2.0',
|
||||
reasons: [
|
||||
{
|
||||
code: 'agent_credential_missing',
|
||||
severity: 'critical',
|
||||
message: 'The credential no longer exists.',
|
||||
},
|
||||
],
|
||||
repairActions: [
|
||||
{
|
||||
code: 'repair_authentication',
|
||||
label: 'Repair authentication',
|
||||
description: 'Generate a fresh scoped credential.',
|
||||
supported: true,
|
||||
platform: 'linux',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
|
||||
expect(target).toMatchObject({
|
||||
status: 'critical',
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: true,
|
||||
commandPlatform: 'linux',
|
||||
});
|
||||
expect(target.commandBlockedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fails closed when duplicate host installations make the repair target ambiguous', () => {
|
||||
const connection = connectionFixture();
|
||||
const [target] = collectInfrastructureAgentDoctorTargets({
|
||||
rows: [rowFixture(connection)],
|
||||
connections: [connection],
|
||||
diagnostics: [
|
||||
diagnosticFixture({
|
||||
status: 'critical',
|
||||
reasons: [
|
||||
{
|
||||
code: 'duplicate_host_agent_installation',
|
||||
severity: 'critical',
|
||||
message: 'Multiple host agents report from this machine.',
|
||||
},
|
||||
...diagnosticFixture().reasons,
|
||||
],
|
||||
repairActions: [
|
||||
{
|
||||
code: 'copy_upgrade_command',
|
||||
label: 'Copy upgrade command',
|
||||
description: 'Unsafe generic repair.',
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
diagnosticsAvailable: true,
|
||||
targetVersion: '6.2.0',
|
||||
});
|
||||
|
||||
expect(target.commandBlockedReason).toContain('Multiple host-installed Pulse Agents');
|
||||
});
|
||||
|
||||
it('does not invent a deployed v0 profile version when no legacy acknowledgement exists', () => {
|
||||
const connection = connectionFixture({
|
||||
agentUpdateAvailable: false,
|
||||
@@ -572,6 +659,7 @@ describe('Agent Doctor model', () => {
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: false,
|
||||
commandPlatform: null,
|
||||
source: 'diagnostics',
|
||||
...overrides,
|
||||
|
||||
+61
@@ -522,4 +522,65 @@ describe('useInfrastructureOperationsState command-building closures', () => {
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Linux authentication repair commands', () => {
|
||||
it('uses a fresh credential through a temporary token file and preserves update flags', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
await state.handleGenerateToken();
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(
|
||||
baseConnection,
|
||||
['--enable-proxmox', '--proxmox-type', 'pve'],
|
||||
'linux',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(cmd).toContain('tmp_dir=$(mktemp -d)');
|
||||
expect(cmd).toContain('printf %s \'tok-1\' > "$token_file"');
|
||||
expect(cmd).toContain('--token-file "$token_file"');
|
||||
expect(cmd).toContain('--update');
|
||||
expect(cmd).toContain('--enable-proxmox');
|
||||
expect(cmd).toContain('--proxmox-type');
|
||||
expect(cmd).toContain('pve');
|
||||
expect(cmd).not.toContain("--token 'tok-1'");
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps an ordinary Linux update tokenless', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
state.setCustomAgentUrl(HTTPS_URL);
|
||||
|
||||
const cmd = state.getAgentConnectionUpgradeCommand(baseConnection, [], 'linux');
|
||||
|
||||
expect(cmd).toContain('| { if [ "$(id -u)" -eq 0 ]; then bash -s -- --update');
|
||||
expect(cmd).not.toContain('--token-file');
|
||||
expect(cmd).not.toContain('mktemp -d');
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('requires a fresh token for explicit or ledger-derived credential repair', async () => {
|
||||
const { state, dispose } = mountHook();
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
state.getAgentConnectionUpgradeCommandRequiresToken(baseConnection, 'linux', true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
state.getAgentConnectionUpgradeCommandRequiresToken(
|
||||
{
|
||||
...baseConnection,
|
||||
fleet: { credentialStatus: 'invalid' } as Connection['fleet'],
|
||||
},
|
||||
'linux',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(state.getAgentConnectionUpgradeCommandRequiresToken(baseConnection, 'linux')).toBe(
|
||||
false,
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export type InfrastructureAgentDoctorTarget = Omit<
|
||||
reasons: AgentFleetDiagnosticReason[];
|
||||
evidence: string[];
|
||||
needsUpdate: boolean;
|
||||
needsCredentialRepair: boolean;
|
||||
commandPlatform: AgentCommandPlatform | null;
|
||||
commandBlockedReason?: string;
|
||||
updaterLabel?: string;
|
||||
@@ -311,6 +312,22 @@ const ledgerFallbackReasons = (
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
connection.state !== 'unauthorized' &&
|
||||
(connection.fleet?.credentialStatus === 'invalid' ||
|
||||
connection.fleet?.credentialHealth?.status === 'expired')
|
||||
) {
|
||||
reasons.push(
|
||||
fallbackReason(
|
||||
'ledger_credential_invalid',
|
||||
'critical',
|
||||
connection.fleet?.credentialHealth?.status === 'expired'
|
||||
? 'The agent credential has expired.'
|
||||
: 'The agent credential is missing or no longer valid on this Pulse server.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (connection.agentUpdate?.state === 'error') {
|
||||
reasons.push(
|
||||
fallbackReason(
|
||||
@@ -482,7 +499,13 @@ const doctorTargetFromBinding = (
|
||||
? Array.from(
|
||||
new Map(
|
||||
[
|
||||
...fallbackReasons.filter((reason) => reason.code === 'command_channel_disconnected'),
|
||||
...fallbackReasons.filter((reason) =>
|
||||
[
|
||||
'command_channel_disconnected',
|
||||
'ledger_credential_invalid',
|
||||
'ledger_unauthorized',
|
||||
].includes(reason.code),
|
||||
),
|
||||
...(diagnostic.reasons ?? []),
|
||||
].map((reason) => [reason.code, reason]),
|
||||
).values(),
|
||||
@@ -514,8 +537,22 @@ const doctorTargetFromBinding = (
|
||||
}
|
||||
|
||||
const commandPlatform = resolveKnownAgentCommandPlatform(connection.agentIdentity?.platform);
|
||||
const needsCredentialRepair = reasons.some((reason) =>
|
||||
[
|
||||
'agent_credential_missing',
|
||||
'agent_credential_expired',
|
||||
'ledger_credential_invalid',
|
||||
'ledger_unauthorized',
|
||||
].includes(reason.code),
|
||||
);
|
||||
const hasDuplicateInstallation = reasons.some(
|
||||
(reason) => reason.code === 'duplicate_host_agent_installation',
|
||||
);
|
||||
let commandBlockedReason: string | undefined;
|
||||
if (needsUpdate && !expectedVersion) {
|
||||
if ((needsUpdate || needsCredentialRepair) && hasDuplicateInstallation) {
|
||||
commandBlockedReason =
|
||||
'Multiple host-installed Pulse Agents are reporting from this machine. Pulse cannot safely choose a local service; inspect and remove or explicitly update the duplicate installation first.';
|
||||
} else if (needsUpdate && !expectedVersion) {
|
||||
commandBlockedReason = 'No supported target version is available, so Pulse will not guess.';
|
||||
} else if (needsUpdate && !parseAgentVersion(expectedVersion)) {
|
||||
commandBlockedReason = 'The reported target version is not a supported release version.';
|
||||
@@ -545,6 +582,22 @@ const doctorTargetFromBinding = (
|
||||
commandBlockedReason = 'The diagnostic service did not offer a supported update repair.';
|
||||
}
|
||||
|
||||
const hasStructuredCredentialRepair = Boolean(
|
||||
diagnostic?.repairActions?.some(
|
||||
(action) => action.code === 'repair_authentication' && action.supported,
|
||||
),
|
||||
);
|
||||
if (
|
||||
needsCredentialRepair &&
|
||||
diagnosticsAvailable &&
|
||||
diagnostic &&
|
||||
!hasStructuredCredentialRepair &&
|
||||
!commandBlockedReason
|
||||
) {
|
||||
commandBlockedReason =
|
||||
'The diagnostic service did not identify a safe local authentication repair path.';
|
||||
}
|
||||
|
||||
const profileLabel =
|
||||
diagnostic?.profileName?.trim() || diagnostic?.profileId?.trim() || undefined;
|
||||
const profileVersionLabel = diagnosticProfileVersionLabel(diagnostic);
|
||||
@@ -563,6 +616,7 @@ const doctorTargetFromBinding = (
|
||||
reasons,
|
||||
evidence: evidenceFor(connection, diagnostic),
|
||||
needsUpdate,
|
||||
needsCredentialRepair,
|
||||
commandPlatform,
|
||||
commandBlockedReason,
|
||||
updaterLabel: updater.label,
|
||||
@@ -626,6 +680,7 @@ const diagnosticOnlyDoctorTarget = (
|
||||
reasons: diagnostic.reasons ?? [],
|
||||
evidence: evidenceFor(undefined, diagnostic),
|
||||
needsUpdate: false,
|
||||
needsCredentialRepair: false,
|
||||
commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform),
|
||||
profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined,
|
||||
profileVersionLabel: diagnosticProfileVersionLabel(diagnostic),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useContext, type ParentComponent } from 'solid-js';
|
||||
import type { Connection } from '@/api/connections';
|
||||
import {
|
||||
buildPowerShellInstallScriptBootstrap,
|
||||
buildUnixAgentInstallCommand,
|
||||
buildWindowsAgentInstallCommand,
|
||||
powerShellQuote,
|
||||
resolveAgentCommandPlatform,
|
||||
@@ -186,6 +187,7 @@ export const useInfrastructureOperationsState = (
|
||||
connection: Connection,
|
||||
installFlags: string[] = [],
|
||||
platformOverride?: AgentPlatform,
|
||||
replaceCredential = false,
|
||||
) => {
|
||||
const token = resolvedCommandToken();
|
||||
const url = installState.selectedAgentUrl();
|
||||
@@ -216,6 +218,20 @@ export const useInfrastructureOperationsState = (
|
||||
});
|
||||
}
|
||||
|
||||
if (replaceCredential) {
|
||||
const extraArgs = ['--update', ...installFlags];
|
||||
if (commandsEnabled || installState.enableCommands()) {
|
||||
extraArgs.push('--enable-commands');
|
||||
}
|
||||
return buildUnixAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraArgs,
|
||||
});
|
||||
}
|
||||
|
||||
let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --update --url ${shellQuoteArg(url)} --non-interactive`;
|
||||
if (installFlags.length > 0) {
|
||||
command += ` ${installFlags.join(' ')}`;
|
||||
@@ -233,9 +249,14 @@ export const useInfrastructureOperationsState = (
|
||||
const getAgentConnectionUpgradeCommandRequiresToken = (
|
||||
connection: Connection,
|
||||
platformOverride?: AgentPlatform,
|
||||
replaceCredential = false,
|
||||
) =>
|
||||
(platformOverride ?? getConnectionUpgradePlatform(connection)) === 'windows' &&
|
||||
installState.requiresToken();
|
||||
installState.requiresToken() &&
|
||||
((platformOverride ?? getConnectionUpgradePlatform(connection)) === 'windows' ||
|
||||
replaceCredential ||
|
||||
connection.state === 'unauthorized' ||
|
||||
connection.fleet?.credentialStatus === 'invalid' ||
|
||||
connection.fleet?.credentialHealth?.status === 'expired');
|
||||
|
||||
return {
|
||||
...installState,
|
||||
|
||||
@@ -90,6 +90,7 @@ const (
|
||||
fleetCredentialRotationExpired = "expired"
|
||||
fleetCredentialRotationExpiring = "expiring"
|
||||
fleetCredentialRotationHealthy = "healthy"
|
||||
fleetCredentialRotationInvalid = "invalid"
|
||||
fleetCredentialRotationNone = "not-applicable"
|
||||
fleetCredentialStatusExpired = "expired"
|
||||
fleetCredentialStatusExpiring = "expiring"
|
||||
@@ -107,21 +108,22 @@ const (
|
||||
// from the handler makes the aggregator unit-testable without spinning up a
|
||||
// monitor or persistence layer.
|
||||
type aggregatorInputs struct {
|
||||
pveInstances []config.PVEInstance
|
||||
pbsInstances []config.PBSInstance
|
||||
pmgInstances []config.PMGInstance
|
||||
vmwareInstances []config.VMwareVCenterInstance
|
||||
vmwareSummaries map[string]monitoring.VMwareConnectionSummary
|
||||
truenasInstances []config.TrueNASInstance
|
||||
truenasSummaries map[string]monitoring.TrueNASConnectionSummary
|
||||
availabilityTargets []config.AvailabilityTarget
|
||||
availabilityStatuses map[string]monitoring.AvailabilityProbeStatus
|
||||
hosts []models.Host
|
||||
apiTokens []config.APITokenRecord
|
||||
agentDesiredConfigs map[string]connectionAgentDesiredConfig
|
||||
instanceHealth map[string]monitoring.InstanceHealth
|
||||
expectedAgentVersion string
|
||||
now time.Time
|
||||
pveInstances []config.PVEInstance
|
||||
pbsInstances []config.PBSInstance
|
||||
pmgInstances []config.PMGInstance
|
||||
vmwareInstances []config.VMwareVCenterInstance
|
||||
vmwareSummaries map[string]monitoring.VMwareConnectionSummary
|
||||
truenasInstances []config.TrueNASInstance
|
||||
truenasSummaries map[string]monitoring.TrueNASConnectionSummary
|
||||
availabilityTargets []config.AvailabilityTarget
|
||||
availabilityStatuses map[string]monitoring.AvailabilityProbeStatus
|
||||
hosts []models.Host
|
||||
apiTokens []config.APITokenRecord
|
||||
agentTokenInventoryKnown bool
|
||||
agentDesiredConfigs map[string]connectionAgentDesiredConfig
|
||||
instanceHealth map[string]monitoring.InstanceHealth
|
||||
expectedAgentVersion string
|
||||
now time.Time
|
||||
|
||||
// pbsReportedNodeNames maps a configured PBS instance name to the hostname
|
||||
// that node reports about itself. Reported identity outranks configured
|
||||
@@ -175,6 +177,7 @@ func buildConnections(in aggregatorInputs) []Connection {
|
||||
for _, target := range in.availabilityTargets {
|
||||
out = append(out, buildAvailabilityConnection(target, in.availabilityStatuses[target.ID], now))
|
||||
}
|
||||
agentTokenRecords := connectionAgentTokenRecordsByID(in.apiTokens)
|
||||
for _, host := range in.hosts {
|
||||
// Integration-monitored machines (vSphere ESXi hosts, TrueNAS boxes)
|
||||
// are represented by their owning platform connection; fabricating an
|
||||
@@ -184,7 +187,7 @@ func buildConnections(in aggregatorInputs) []Connection {
|
||||
continue
|
||||
}
|
||||
desiredConfig := connectionAgentDesiredConfigForHost(in.agentDesiredConfigs, host.ID)
|
||||
out = append(out, buildAgentConnection(host, in.expectedAgentVersion, now, desiredConfig))
|
||||
out = append(out, buildAgentConnection(host, in.expectedAgentVersion, now, desiredConfig, agentTokenRecords[strings.TrimSpace(host.TokenID)], in.agentTokenInventoryKnown))
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -522,7 +525,7 @@ func buildAvailabilityConnection(target config.AvailabilityTarget, status monito
|
||||
// buildAgentConnection derives a connection row from an agent Host record.
|
||||
// Agents have no pause toggle and no scope — reports are all-or-nothing —
|
||||
// so capability flags are off.
|
||||
func buildAgentConnection(host models.Host, expectedAgentVersion string, now time.Time, desiredConfig *connectionAgentDesiredConfig) Connection {
|
||||
func buildAgentConnection(host models.Host, expectedAgentVersion string, now time.Time, desiredConfig *connectionAgentDesiredConfig, tokenRecord *config.APITokenRecord, tokenInventoryKnown bool) Connection {
|
||||
name := host.DisplayName
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = host.Hostname
|
||||
@@ -583,8 +586,11 @@ func buildAgentConnection(host models.Host, expectedAgentVersion string, now tim
|
||||
agentID: strings.TrimSpace(host.ID),
|
||||
agentTokenID: strings.TrimSpace(host.TokenID),
|
||||
}, now)
|
||||
if tokenInventoryKnown && agentCredentialRecordInvalid(host, tokenRecord, now) {
|
||||
conn.Fleet.CredentialStatus = fleetStateInvalid
|
||||
}
|
||||
conn.Fleet.ConfigDrift = connectionFleetAgentConfigDrift(conn, desiredConfig, host.AppliedConfig)
|
||||
conn.Fleet.CredentialHealth = connectionFleetAgentCredentialHealth(conn, host, now)
|
||||
conn.Fleet.CredentialHealth = connectionFleetAgentCredentialHealth(conn, host, tokenRecord, tokenInventoryKnown, now)
|
||||
conn.Fleet.CommandPolicy = connectionFleetAgentCommandPolicy(conn, host, desiredConfig)
|
||||
conn.Fleet.Rollout = connectionFleetRollout(conn)
|
||||
return conn
|
||||
@@ -994,12 +1000,34 @@ func connectionFleetCredentialHealthNotApplicable() *ConnectionFleetCredentialHe
|
||||
}
|
||||
}
|
||||
|
||||
func connectionFleetAgentCredentialHealth(conn Connection, host models.Host, now time.Time) *ConnectionFleetCredentialHealth {
|
||||
func agentCredentialRecordInvalid(host models.Host, record *config.APITokenRecord, now time.Time) bool {
|
||||
if strings.TrimSpace(host.TokenID) == "" {
|
||||
return false
|
||||
}
|
||||
if record == nil {
|
||||
return true
|
||||
}
|
||||
return record.ExpiresAt != nil && !record.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
func connectionFleetAgentCredentialHealth(conn Connection, host models.Host, record *config.APITokenRecord, tokenInventoryKnown bool, now time.Time) *ConnectionFleetCredentialHealth {
|
||||
kind := fleetStateUnknown
|
||||
if strings.TrimSpace(host.TokenID) != "" || strings.TrimSpace(host.TokenName) != "" || strings.TrimSpace(host.TokenHint) != "" {
|
||||
kind = fleetCredentialKindAgentToken
|
||||
}
|
||||
return connectionFleetCredentialHealth(conn, kind, host.TokenLastUsedAt, nil, now)
|
||||
lastUsedAt := host.TokenLastUsedAt
|
||||
var expiresAt *time.Time
|
||||
if record != nil {
|
||||
lastUsedAt = record.LastUsedAt
|
||||
expiresAt = record.ExpiresAt
|
||||
}
|
||||
health := connectionFleetCredentialHealth(conn, kind, lastUsedAt, expiresAt, now)
|
||||
if tokenInventoryKnown && strings.TrimSpace(host.TokenID) != "" && record == nil {
|
||||
health.Status = fleetStateInvalid
|
||||
health.Rotation = fleetCredentialRotationInvalid
|
||||
health.LastVerifiedAt = nil
|
||||
}
|
||||
return health
|
||||
}
|
||||
|
||||
func connectionFleetCommandPolicy(conn Connection) *ConnectionFleetCommandPolicy {
|
||||
|
||||
@@ -620,6 +620,102 @@ func TestBuildConnections_AgentFleetGovernance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_AgentCredentialInventoryDrivesFleetHealth(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
lastUsedAt := now.Add(-time.Minute)
|
||||
expiredAt := now.Add(-time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenID string
|
||||
tokens []config.APITokenRecord
|
||||
wantStatus string
|
||||
wantHealthStatus string
|
||||
wantHealthRotation string
|
||||
}{
|
||||
{
|
||||
name: "active",
|
||||
tokenID: "active-token",
|
||||
tokens: []config.APITokenRecord{{ID: "active-token", LastUsedAt: &lastUsedAt}},
|
||||
wantStatus: fleetStateVerified,
|
||||
wantHealthStatus: fleetStateVerified,
|
||||
wantHealthRotation: fleetCredentialRotationHealthy,
|
||||
},
|
||||
{
|
||||
name: "missing or revoked",
|
||||
tokenID: "missing-token",
|
||||
wantStatus: fleetStateInvalid,
|
||||
wantHealthStatus: fleetStateInvalid,
|
||||
wantHealthRotation: fleetCredentialRotationInvalid,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
tokenID: "expired-token",
|
||||
tokens: []config.APITokenRecord{{
|
||||
ID: "expired-token",
|
||||
ExpiresAt: &expiredAt,
|
||||
}},
|
||||
wantStatus: fleetStateInvalid,
|
||||
wantHealthStatus: fleetCredentialStatusExpired,
|
||||
wantHealthRotation: fleetCredentialRotationExpired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
connections := buildConnections(aggregatorInputs{
|
||||
apiTokens: test.tokens,
|
||||
agentTokenInventoryKnown: true,
|
||||
hosts: []models.Host{{
|
||||
ID: "agent-auth",
|
||||
Hostname: "agent-auth",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.2.0",
|
||||
TokenID: test.tokenID,
|
||||
}},
|
||||
expectedAgentVersion: "6.2.0",
|
||||
now: now,
|
||||
})
|
||||
if len(connections) != 1 {
|
||||
t.Fatalf("connections = %d, want 1", len(connections))
|
||||
}
|
||||
fleet := connections[0].Fleet
|
||||
if fleet.CredentialStatus != test.wantStatus {
|
||||
t.Fatalf("credential status = %q, want %q", fleet.CredentialStatus, test.wantStatus)
|
||||
}
|
||||
if fleet.CredentialHealth == nil ||
|
||||
fleet.CredentialHealth.Status != test.wantHealthStatus ||
|
||||
fleet.CredentialHealth.Rotation != test.wantHealthRotation {
|
||||
t.Fatalf("credential health = %+v, want status %q rotation %q", fleet.CredentialHealth, test.wantHealthStatus, test.wantHealthRotation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_UnknownCredentialInventoryDoesNotInventInvalidState(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
connections := buildConnections(aggregatorInputs{
|
||||
hosts: []models.Host{{
|
||||
ID: "agent-mock",
|
||||
Hostname: "agent-mock",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.2.0",
|
||||
TokenID: "synthetic-token",
|
||||
}},
|
||||
expectedAgentVersion: "6.2.0",
|
||||
now: now,
|
||||
})
|
||||
|
||||
if len(connections) != 1 {
|
||||
t.Fatalf("connections = %d, want 1", len(connections))
|
||||
}
|
||||
if got := connections[0].Fleet.CredentialStatus; got != fleetStateVerified {
|
||||
t.Fatalf("credential status = %q, want %q when inventory is unavailable", got, fleetStateVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_AgentWithoutManagedDesiredConfigDoesNotReportRolloutPending(t *testing.T) {
|
||||
now := time.Now()
|
||||
in := aggregatorInputs{
|
||||
|
||||
@@ -31,6 +31,7 @@ func buildAggregatorInputsWithRuntimeSources(
|
||||
inputs.pbsInstances = cfg.PBSInstances
|
||||
inputs.pmgInstances = cfg.PMGInstances
|
||||
inputs.apiTokens = append([]config.APITokenRecord(nil), cfg.APITokens...)
|
||||
inputs.agentTokenInventoryKnown = true
|
||||
inputs.pvePollingInterval = cfg.PVEPollingInterval
|
||||
inputs.pbsPollingInterval = cfg.PBSPollingInterval
|
||||
inputs.pmgPollingInterval = cfg.PMGPollingInterval
|
||||
@@ -72,6 +73,7 @@ func buildAggregatorInputsWithRuntimeSources(
|
||||
}
|
||||
if mock.IsMockEnabled() {
|
||||
inputs = applyMockLedgerInputs(inputs)
|
||||
inputs.agentTokenInventoryKnown = false
|
||||
}
|
||||
inputs.expectedAgentVersion = currentAgentTargetVersion()
|
||||
_ = ctx
|
||||
|
||||
@@ -16216,6 +16216,9 @@ func TestContract_LedgerDropsIntegrationMonitoredMachines(t *testing.T) {
|
||||
func TestContract_AgentDefaultDesiredConfigDoesNotCreateRolloutAttention(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC)
|
||||
connections := buildConnections(aggregatorInputs{
|
||||
apiTokens: []config.APITokenRecord{{
|
||||
ID: "token-1",
|
||||
}},
|
||||
hosts: []models.Host{
|
||||
{
|
||||
ID: "host-1",
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/fleethealth"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/platformsupport"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
@@ -33,10 +35,14 @@ const (
|
||||
AgentFleetReasonUpdateStateUnverified = "agent_update_installer_state_unverified"
|
||||
AgentFleetReasonModuleFailed = "agent_module_failed"
|
||||
AgentFleetReasonModuleDegraded = "agent_module_degraded"
|
||||
AgentFleetReasonCredentialMissing = "agent_credential_missing"
|
||||
AgentFleetReasonCredentialExpired = "agent_credential_expired"
|
||||
AgentFleetReasonDuplicateInstallation = "duplicate_host_agent_installation"
|
||||
|
||||
AgentFleetActionAllowReenroll = "allow_reenroll"
|
||||
AgentFleetActionCopyUpgradeCommand = "copy_upgrade_command"
|
||||
AgentFleetRepairModeHandoff = "handoff"
|
||||
AgentFleetActionAllowReenroll = "allow_reenroll"
|
||||
AgentFleetActionCopyUpgradeCommand = "copy_upgrade_command"
|
||||
AgentFleetActionRepairAuthentication = "repair_authentication"
|
||||
AgentFleetRepairModeHandoff = "handoff"
|
||||
)
|
||||
|
||||
type AgentFleetDiagnostics struct {
|
||||
@@ -143,6 +149,12 @@ type agentFleetSubject struct {
|
||||
removedPlatform string // last-known reported platform retained on the removed record
|
||||
}
|
||||
|
||||
type agentFleetTokenInventory struct {
|
||||
known bool
|
||||
active map[string]struct{}
|
||||
expired map[string]struct{}
|
||||
}
|
||||
|
||||
// GetAgentFleetDiagnostics preserves the original call contract for callers
|
||||
// where the server version is also the agent update target.
|
||||
func (m *Monitor) GetAgentFleetDiagnostics(serverVersion string, now time.Time) AgentFleetDiagnostics {
|
||||
@@ -169,6 +181,7 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
|
||||
}
|
||||
|
||||
state := m.GetState()
|
||||
tokenInventory := m.agentFleetTokenInventory(now)
|
||||
profiles, assignments, deployments := m.agentFleetProfileState()
|
||||
profileByID := mapProfilesByID(profiles)
|
||||
assignmentByAgent := mapAssignmentsByAgent(assignments)
|
||||
@@ -176,7 +189,7 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
|
||||
subjects := buildAgentFleetSubjects(state)
|
||||
|
||||
for i := range subjects {
|
||||
diagnostic := diagnoseAgentFleetSubject(subjects[i], state, out.AgentUpdateTargetVersion, now, profileByID, assignmentByAgent, deploymentByAgentProfile)
|
||||
diagnostic := diagnoseAgentFleetSubject(subjects[i], state, out.AgentUpdateTargetVersion, now, tokenInventory, profileByID, assignmentByAgent, deploymentByAgentProfile)
|
||||
out.Agents = append(out.Agents, diagnostic)
|
||||
}
|
||||
|
||||
@@ -204,6 +217,32 @@ func (m *Monitor) GetAgentFleetDiagnosticsForTarget(serverVersion, agentUpdateTa
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Monitor) agentFleetTokenInventory(now time.Time) agentFleetTokenInventory {
|
||||
if m == nil || m.config == nil || mock.IsMockEnabled() {
|
||||
return agentFleetTokenInventory{}
|
||||
}
|
||||
|
||||
inventory := agentFleetTokenInventory{
|
||||
known: true,
|
||||
active: make(map[string]struct{}),
|
||||
expired: make(map[string]struct{}),
|
||||
}
|
||||
config.Mu.RLock()
|
||||
defer config.Mu.RUnlock()
|
||||
for _, record := range m.config.APITokens {
|
||||
id := strings.TrimSpace(record.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(now) {
|
||||
inventory.expired[id] = struct{}{}
|
||||
continue
|
||||
}
|
||||
inventory.active[id] = struct{}{}
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
func (m *Monitor) agentFleetProfileState() ([]models.AgentProfile, []models.AgentProfileAssignment, []models.ProfileDeploymentStatus) {
|
||||
if m == nil || m.persistence == nil {
|
||||
return nil, nil, nil
|
||||
@@ -352,6 +391,7 @@ func diagnoseAgentFleetSubject(
|
||||
state models.StateSnapshot,
|
||||
serverVersion string,
|
||||
now time.Time,
|
||||
tokenInventory agentFleetTokenInventory,
|
||||
profileByID map[string]models.AgentProfile,
|
||||
assignmentByAgent map[string]models.AgentProfileAssignment,
|
||||
deploymentByAgentProfile map[string]models.ProfileDeploymentStatus,
|
||||
@@ -408,9 +448,11 @@ func diagnoseAgentFleetSubject(
|
||||
}
|
||||
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentConnectivity(subject, now)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentCredential(subject, tokenInventory)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentVersion(subject, serverVersion)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentUpdate(subject, serverVersion)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentModules(subject)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseDuplicateHostAgentInstallation(subject, state)...)
|
||||
result.Reasons = append(result.Reasons, diagnoseAgentIdentitySplit(subject, state)...)
|
||||
|
||||
assignment, hasAssignment := findAgentAssignment(subject, assignmentByAgent)
|
||||
@@ -438,6 +480,22 @@ func diagnoseAgentFleetSubject(
|
||||
}
|
||||
}
|
||||
|
||||
if diagnosticHasAnyReason(result.Reasons, AgentFleetReasonCredentialMissing, AgentFleetReasonCredentialExpired) {
|
||||
platform, supported := safeAgentUpdatePlatform(subject)
|
||||
if platform == platformsupport.RuntimePlatformFreeBSD || diagnosticHasAnyReason(result.Reasons, AgentFleetReasonDuplicateInstallation) {
|
||||
supported = false
|
||||
}
|
||||
result.RepairActions = append(result.RepairActions, AgentFleetDiagnosticRepair{
|
||||
Code: AgentFleetActionRepairAuthentication,
|
||||
Label: "Repair authentication",
|
||||
Description: "Generate a fresh scoped agent credential and run the host-local repair command for this installation.",
|
||||
Supported: supported,
|
||||
Mode: AgentFleetRepairModeHandoff,
|
||||
Platform: platform,
|
||||
Scope: "settings:write + local_admin_shell",
|
||||
})
|
||||
}
|
||||
|
||||
for _, reason := range result.Reasons {
|
||||
if reason.Code == "agent_version_stale" {
|
||||
platform, supported := safeAgentUpdatePlatform(subject)
|
||||
@@ -455,6 +513,9 @@ func diagnoseAgentFleetSubject(
|
||||
Message: "Pulse cannot verify the saved FreeBSD or pfSense installer state required for a safe in-place update.",
|
||||
})
|
||||
}
|
||||
if diagnosticHasAnyReason(result.Reasons, AgentFleetReasonDuplicateInstallation) {
|
||||
supported = false
|
||||
}
|
||||
result.RepairActions = append(result.RepairActions, AgentFleetDiagnosticRepair{
|
||||
Code: AgentFleetActionCopyUpgradeCommand,
|
||||
Label: "Copy upgrade command",
|
||||
@@ -472,6 +533,77 @@ func diagnoseAgentFleetSubject(
|
||||
return result
|
||||
}
|
||||
|
||||
func diagnosticHasAnyReason(reasons []AgentFleetDiagnosticReason, codes ...string) bool {
|
||||
for _, reason := range reasons {
|
||||
for _, code := range codes {
|
||||
if reason.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func diagnoseAgentCredential(subject agentFleetSubject, inventory agentFleetTokenInventory) []AgentFleetDiagnosticReason {
|
||||
tokenID := strings.TrimSpace(subject.tokenID)
|
||||
if subject.removed || !inventory.known || tokenID == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := inventory.active[tokenID]; ok {
|
||||
return nil
|
||||
}
|
||||
if _, ok := inventory.expired[tokenID]; ok {
|
||||
return []AgentFleetDiagnosticReason{{
|
||||
Code: AgentFleetReasonCredentialExpired,
|
||||
Severity: AgentFleetStatusCritical,
|
||||
Message: "The credential last used by this agent has expired and can no longer authenticate.",
|
||||
Evidence: []string{"Credential status: expired"},
|
||||
}}
|
||||
}
|
||||
return []AgentFleetDiagnosticReason{{
|
||||
Code: AgentFleetReasonCredentialMissing,
|
||||
Severity: AgentFleetStatusCritical,
|
||||
Message: "The credential last used by this agent no longer exists on this Pulse server.",
|
||||
Evidence: []string{"Credential status: missing or revoked"},
|
||||
}}
|
||||
}
|
||||
|
||||
func diagnoseDuplicateHostAgentInstallation(subject agentFleetSubject, state models.StateSnapshot) []AgentFleetDiagnosticReason {
|
||||
if subject.removed || subject.host == nil {
|
||||
return nil
|
||||
}
|
||||
machineID := strings.TrimSpace(subject.host.MachineID)
|
||||
hostname := strings.TrimSpace(subject.host.Hostname)
|
||||
if machineID == "" || hostname == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, peer := range state.Hosts {
|
||||
if strings.TrimSpace(peer.ID) == strings.TrimSpace(subject.host.ID) ||
|
||||
strings.TrimSpace(peer.MachineID) != machineID ||
|
||||
!unifiedresources.HostnamesEquivalent(peer.Hostname, hostname) {
|
||||
continue
|
||||
}
|
||||
evidence := []string{
|
||||
"Peer agent ID: " + strings.TrimSpace(peer.ID),
|
||||
"Peer hostname: " + strings.TrimSpace(peer.Hostname),
|
||||
}
|
||||
if version := strings.TrimSpace(peer.AgentVersion); version != "" {
|
||||
evidence = append(evidence, "Peer version: "+version)
|
||||
}
|
||||
if !peer.LastSeen.IsZero() {
|
||||
evidence = append(evidence, "Peer last seen: "+peer.LastSeen.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return []AgentFleetDiagnosticReason{{
|
||||
Code: AgentFleetReasonDuplicateInstallation,
|
||||
Severity: AgentFleetStatusCritical,
|
||||
Message: "Multiple host-installed Pulse Agents are reporting from the same machine. A generic update command cannot safely choose which local service to change.",
|
||||
Evidence: evidence,
|
||||
}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func diagnoseAgentConnectivity(subject agentFleetSubject, now time.Time) []AgentFleetDiagnosticReason {
|
||||
if subject.kubernetes == nil && subject.host == nil && subject.docker == nil {
|
||||
return nil
|
||||
|
||||
@@ -37,6 +37,129 @@ func TestAgentFleetDiagnosticsDetectsStaleAgentVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentFleetDiagnosticsDetectsMissingAndExpiredCredentials(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
expiredAt := now.Add(-time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenID string
|
||||
tokens []config.APITokenRecord
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "missing or revoked",
|
||||
tokenID: "revoked-token",
|
||||
wantReason: AgentFleetReasonCredentialMissing,
|
||||
},
|
||||
{
|
||||
name: "expired",
|
||||
tokenID: "expired-token",
|
||||
tokens: []config.APITokenRecord{{
|
||||
ID: "expired-token",
|
||||
ExpiresAt: &expiredAt,
|
||||
}},
|
||||
wantReason: AgentFleetReasonCredentialExpired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
monitor := newAgentFleetDoctorTestMonitor(t)
|
||||
monitor.config.APITokens = test.tokens
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: "agent-auth",
|
||||
Hostname: "auth-node",
|
||||
Platform: "linux",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.2.0",
|
||||
TokenID: test.tokenID,
|
||||
})
|
||||
|
||||
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-auth")
|
||||
requireReasonCode(t, agent, test.wantReason)
|
||||
if agent.Status != AgentFleetStatusCritical {
|
||||
t.Fatalf("status = %q, want %q", agent.Status, AgentFleetStatusCritical)
|
||||
}
|
||||
if !hasSupportedRepair(agent, AgentFleetActionRepairAuthentication) {
|
||||
t.Fatalf("expected safe authentication repair action: %#v", agent.RepairActions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentFleetDiagnosticsAcceptsActiveCredential(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
monitor := newAgentFleetDoctorTestMonitor(t)
|
||||
monitor.config.APITokens = []config.APITokenRecord{{ID: "active-token"}}
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: "agent-auth",
|
||||
Hostname: "auth-node",
|
||||
Platform: "linux",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.2.0",
|
||||
TokenID: "active-token",
|
||||
})
|
||||
|
||||
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0", now), "agent-agent-auth")
|
||||
for _, reason := range agent.Reasons {
|
||||
if reason.Code == AgentFleetReasonCredentialMissing || reason.Code == AgentFleetReasonCredentialExpired {
|
||||
t.Fatalf("active credential produced repair reason: %#v", agent.Reasons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentFleetDiagnosticsDoesNotValidateSyntheticMockCredentials(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
mustSetMockEnabled(t, true)
|
||||
t.Cleanup(func() { mustSetMockEnabled(t, false) })
|
||||
monitor := newAgentFleetDoctorTestMonitor(t)
|
||||
monitor.config.APITokens = []config.APITokenRecord{{ID: "synthetic-token"}}
|
||||
|
||||
if inventory := monitor.agentFleetTokenInventory(now); inventory.known {
|
||||
t.Fatalf("mock token inventory = %+v, want deliberately unknown", inventory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentFleetDiagnosticsBlocksGenericRepairForDuplicateHostInstallations(t *testing.T) {
|
||||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||||
monitor := newAgentFleetDoctorTestMonitor(t)
|
||||
for _, host := range []models.Host{
|
||||
{
|
||||
ID: "agent-primary",
|
||||
Hostname: "pi",
|
||||
MachineID: "machine-pi",
|
||||
Platform: "linux",
|
||||
Status: "online",
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.2.0-rc.11",
|
||||
},
|
||||
{
|
||||
ID: "agent-legacy",
|
||||
Hostname: "PI.local",
|
||||
MachineID: "machine-pi",
|
||||
Platform: "linux",
|
||||
Status: "online",
|
||||
LastSeen: now.Add(-time.Minute),
|
||||
AgentVersion: "6.2.0-rc.6",
|
||||
},
|
||||
} {
|
||||
monitor.state.UpsertHost(host)
|
||||
}
|
||||
|
||||
for _, rowKey := range []string{"agent-agent-primary", "agent-agent-legacy"} {
|
||||
agent := requireAgentDiagnostic(t, monitor.GetAgentFleetDiagnostics("6.2.0-rc.11", now), rowKey)
|
||||
requireReasonCode(t, agent, AgentFleetReasonDuplicateInstallation)
|
||||
for _, repair := range agent.RepairActions {
|
||||
if (repair.Code == AgentFleetActionCopyUpgradeCommand || repair.Code == AgentFleetActionRepairAuthentication) && repair.Supported {
|
||||
t.Fatalf("duplicate installation offered unsafe generic repair: %#v", agent.RepairActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentFleetDiagnosticsSurfacesReportedUpdateModuleAndIdentityEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
|
||||
checkedAt := now.Add(-time.Minute)
|
||||
|
||||
+11
-1
@@ -233,6 +233,7 @@ EXIT_PREFLIGHT_FAILED=14
|
||||
EXIT_ALREADY_INSTALLED=15 # Not a failure — used with --preflight-only
|
||||
EXIT_MISSING_ARGS=16
|
||||
EXIT_SIGNATURE_FAILED=17
|
||||
EXIT_AUTH_REJECTED=18
|
||||
|
||||
json_event() {
|
||||
# Usage: json_event <phase> <code> <message> [exitCode]
|
||||
@@ -641,6 +642,7 @@ verify_agent_started() {
|
||||
log_info "Agent process is running and registered with Pulse."
|
||||
elif [[ $reg_rc -eq 2 ]]; then
|
||||
warn_agent_token_rejected
|
||||
return 2
|
||||
else
|
||||
log_warn "Agent process is running, but server registration was not confirmed yet."
|
||||
fi
|
||||
@@ -667,6 +669,7 @@ verify_agent_started() {
|
||||
log_info "Agent is running, healthy, and registered with Pulse."
|
||||
elif [[ $reg_rc -eq 2 ]]; then
|
||||
warn_agent_token_rejected
|
||||
return 2
|
||||
else
|
||||
log_warn "Agent local health is ready, but server registration was not confirmed yet."
|
||||
fi
|
||||
@@ -1296,8 +1299,11 @@ complete_installation_flow() {
|
||||
local upgrade_success_message="$3"
|
||||
local unhealthy_log_hint="$4"
|
||||
|
||||
local verification_rc=0
|
||||
|
||||
save_connection_info "$state_dir"
|
||||
if verify_agent_started; then
|
||||
verify_agent_started || verification_rc=$?
|
||||
if [[ $verification_rc -eq 0 ]]; then
|
||||
report_proxmox_registration_outcome "$state_dir" || true
|
||||
if [[ "$UPGRADE_MODE" == "true" ]]; then
|
||||
log_info "$upgrade_success_message"
|
||||
@@ -1306,6 +1312,10 @@ complete_installation_flow() {
|
||||
log_info "$install_success_message"
|
||||
json_event "complete" "installed" "Installation installed"
|
||||
fi
|
||||
elif [[ $verification_rc -eq 2 ]]; then
|
||||
log_error "Pulse Agent authentication failed. The local service is running, but Pulse rejected its credential; installation is not complete. Generate a fresh scoped agent credential in Pulse and run the repair command again."
|
||||
json_event "complete" "auth_rejected" "Pulse rejected the agent credential" "$EXIT_AUTH_REJECTED"
|
||||
exit "$EXIT_AUTH_REJECTED"
|
||||
else
|
||||
if [[ "$UPGRADE_MODE" == "true" ]]; then
|
||||
log_warn "Upgrade complete, but the agent may not be running correctly."
|
||||
|
||||
@@ -4874,6 +4874,38 @@ func TestInstallSHRegistrationRetryWindowOutlastsFirstReportCycle(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSHRejectedCredentialPreventsSuccessfulCompletion(t *testing.T) {
|
||||
completeFlow := extractInstallShellFunction(t, "complete_installation_flow")
|
||||
script := `
|
||||
EXIT_AUTH_REJECTED=18
|
||||
UPGRADE_MODE="true"
|
||||
save_connection_info() { :; }
|
||||
verify_agent_started() { return 2; }
|
||||
report_proxmox_registration_outcome() { printf 'unexpected-proxmox-report\n'; }
|
||||
log_info() { printf 'INFO:%s\n' "$*"; }
|
||||
log_warn() { printf 'WARN:%s\n' "$*"; }
|
||||
log_error() { printf 'ERROR:%s\n' "$*"; }
|
||||
json_event() { printf 'JSON:%s:%s:%s:%s\n' "$1" "$2" "$3" "${4:-}"; }
|
||||
` + completeFlow + `
|
||||
complete_installation_flow "/tmp/state" "INSTALL-SUCCESS" "UPGRADE-SUCCESS" "logs"
|
||||
printf 'unreachable\n'
|
||||
`
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 18 {
|
||||
t.Fatalf("rejected credential exit = %v, want 18\n%s", err, out)
|
||||
}
|
||||
output := string(out)
|
||||
if !strings.Contains(output, "JSON:complete:auth_rejected") || !strings.Contains(output, "authentication failed") {
|
||||
t.Fatalf("rejected credential did not produce actionable non-success completion:\n%s", output)
|
||||
}
|
||||
for _, forbidden := range []string{"INSTALL-SUCCESS", "UPGRADE-SUCCESS", "unreachable", "unexpected-proxmox-report"} {
|
||||
if strings.Contains(output, forbidden) {
|
||||
t.Fatalf("rejected credential emitted forbidden success output %q:\n%s", forbidden, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallSHSurfacesBlockedProxmoxRegistration verifies the installer reads
|
||||
// the agent's proxmox-<type>-registration-blocked marker and prints the denial
|
||||
// in its own output instead of leaving it buried in the agent journal (#1644).
|
||||
|
||||
Reference in New Issue
Block a user