mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add update readiness checks for v6 upgrades
Surface server upgrade readiness on update plans so v5 agent continuity and agent token scope issues are checked before install.
This commit is contained in:
parent
94992685d1
commit
e1d1ec1d7c
21 changed files with 887 additions and 17 deletions
|
|
@ -16,6 +16,7 @@ For the current v6 prerelease packet, see:
|
|||
## Before You Upgrade
|
||||
|
||||
- Create an encrypted config backup: **Settings → System → Recovery → Create Backup** (older versions labeled this **Backups**)
|
||||
- Open **Settings → System → Updates** and review the upgrade checks on the update plan. Pulse checks the server update path, current agent continuity, and agent reporting token scope before you install.
|
||||
- Confirm you can access the host/container console (for rollback and bootstrap token retrieval)
|
||||
- If you have any external integrations or scripts: review the **API Changes** section below
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@ reverse.
|
|||
wrapper.
|
||||
24. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
|
||||
Server update planning is part of the same lifecycle contract. The System
|
||||
Updates plan must surface a structured upgrade-readiness verdict before an
|
||||
operator installs a v6 update: server update path, registered agent continuity,
|
||||
and agent reporting token scope must be derived from the existing updater,
|
||||
connections ledger, and API token state rather than from docs-only guidance or
|
||||
a parallel migration registry. v5 or legacy agents that are still reporting
|
||||
must be treated as compatible with the v6 reporting boundary, while stale
|
||||
agents, missing reported versions, missing agent reporting scope, or expired
|
||||
agent tokens must be called out before the update starts.
|
||||
|
||||
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` and
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ platform page needs source-native backup columns.
|
|||
7. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary.
|
||||
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
It must preserve `/api/updates/plan.readiness` payloads as backend-owned
|
||||
API state so settings UI can render `ready`, `attention`, and `blocked`
|
||||
update checks without rebuilding upgrade state locally.
|
||||
10. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
The API token inventory table may own credential and usage cells, but it
|
||||
must inherit embedded table framing from `frontend-primitives`
|
||||
|
|
@ -422,6 +425,10 @@ platform page needs source-native backup columns.
|
|||
target, so installer preflight failures point operators at the artifact
|
||||
they actually need.
|
||||
53. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
Update-plan responses own the structured readiness verdict for server
|
||||
updater capability, rollback support, agent continuity, and agent reporting
|
||||
token scope. That verdict is part of the update-plan API contract, not a
|
||||
settings-only migration registry.
|
||||
The platform-connections API contract also owns inactive monitored-system
|
||||
candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview,
|
||||
test, add, and update payloads must serialize through the shared ledger client
|
||||
|
|
@ -3655,6 +3662,11 @@ transport error on supported non-auto-update deployments: `manual`,
|
|||
plan payload instead of `404 No updater for deployment type`, so first-session
|
||||
and settings surfaces do not treat valid deployment modes as broken update
|
||||
transport.
|
||||
The same update-plan contract now carries an optional `readiness` verdict.
|
||||
Backend handlers own the `ready` / `attention` / `blocked` status vocabulary
|
||||
and per-check payload shape, while frontend clients must preserve that payload
|
||||
unchanged so settings surfaces can disable automatic install on blocked checks
|
||||
without inventing a parallel migration state model.
|
||||
Those same install-command payloads now also carry a non-TLS continuity
|
||||
contract: when Pulse returns a plain `http://` base URL for a generated agent
|
||||
install command, the command must include `--insecure` so the installed agent
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ server-side update execution surfaces.
|
|||
enrollment runtime tokens while keeping deploy binding metadata limited to
|
||||
deploy facts such as cluster, job, target, source agent, and expected node.
|
||||
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
|
||||
Server update planning must attach the canonical upgrade-readiness verdict
|
||||
to `/api/updates/plan` responses before an operator starts a v6 update.
|
||||
The verdict belongs to the update plan, not to a separate migration wizard:
|
||||
it must combine updater capability, rollback availability, registered agent
|
||||
continuity, and agent reporting token scope so v5-to-v6 continuity problems
|
||||
are visible before relaunch.
|
||||
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, deterministic dev auth seeding, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/lib/hot-dev-runtime.sh`, `scripts/lib/hot-dev-auth.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
|
||||
First-run browser helpers are part of that dev-runtime proof boundary. They
|
||||
must preserve the setup-created API token in the shared runtime state, prefer
|
||||
|
|
|
|||
|
|
@ -161,6 +161,12 @@ work extends shared components instead of creating new local variants.
|
|||
|
||||
## Shared Boundaries
|
||||
|
||||
The System Updates install guide is a shared settings primitive, not a
|
||||
deployment-lane-only panel. `UpdateInstallGuide` must render the canonical
|
||||
update-plan readiness verdict inline with the update action, and a blocked
|
||||
readiness status must make automatic install visibly unavailable until the
|
||||
blocking check is resolved.
|
||||
|
||||
Platform page subnavigation is a shared frontend primitive. Docker / Podman
|
||||
and Kubernetes platform pages may add native API-backed sections, but the tabs
|
||||
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
|
||||
|
|
|
|||
|
|
@ -177,6 +177,10 @@ regression protection.
|
|||
process version once, but they must not add per-request release lookups,
|
||||
filesystem walks, or other heavy work just to compute whether an attached
|
||||
agent is current.
|
||||
Update-readiness planning follows the same bounded-router rule. The router
|
||||
may wire cached config and monitor host snapshots into the updates handler,
|
||||
but it must not add release lookups, broad persistence scans, or live agent
|
||||
probes to protected request setup before `/api/updates/plan` owns the work.
|
||||
Proxmox-side LXC Docker collector wiring in `internal/api/router.go` follows
|
||||
the same startup-only router rule: env opt-in may configure monitoring-owned
|
||||
checker/collector callbacks, but normal protected request setup must not run
|
||||
|
|
|
|||
|
|
@ -503,6 +503,11 @@ That same trust boundary also governs API token scope identity: legacy
|
|||
migration boundaries, where they must be rewritten immediately into canonical
|
||||
`agent:*` scopes. Live token records and runtime scope checks may not keep the
|
||||
legacy scope names as an active second contract.
|
||||
Update-readiness checks may inspect loaded API token metadata to determine
|
||||
whether agent reporting scope exists or has expired, but they must not expose
|
||||
raw token values, token hashes, or owner metadata in the update plan payload.
|
||||
Legacy `host-agent:*` continuity must be reported only after the loaded token
|
||||
record has normalized to canonical `agent:*` scope.
|
||||
That same token-scope boundary also owns audit-log least privilege: audit
|
||||
event, verification, summary, export, and unified action/export audit reads
|
||||
must require the dedicated `audit:read` scope instead of inheriting broader
|
||||
|
|
|
|||
|
|
@ -211,6 +211,11 @@ recovery scope, or a storage/recovery-owned secret source.
|
|||
|
||||
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
|
||||
4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes
|
||||
Update-plan readiness payloads are adjacent shared API context only. Storage
|
||||
and recovery surfaces may observe the resulting update state if a future
|
||||
settings flow links to recovery preparation, but they must not reinterpret
|
||||
agent-token or server-update readiness checks as backup freshness, restore
|
||||
capability, or storage-provider health.
|
||||
Shared API-token transport helpers may be consumed by storage/recovery-
|
||||
adjacent flows, but `owner_user_id` remains server-authored token identity
|
||||
metadata; storage/recovery extensions must not pass metadata that authors
|
||||
|
|
|
|||
|
|
@ -53,6 +53,29 @@ describe('UpdatesAPI', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves update-plan readiness payloads from the backend', async () => {
|
||||
const response = {
|
||||
canAutoUpdate: true,
|
||||
requiresRoot: true,
|
||||
rollbackSupport: true,
|
||||
readiness: {
|
||||
status: 'blocked',
|
||||
summary: 'Resolve 1 blocked upgrade check before installing this update.',
|
||||
checks: [
|
||||
{
|
||||
id: 'agent-token-scopes',
|
||||
status: 'blocked',
|
||||
title: 'Agent token scopes',
|
||||
summary: 'Registered agents exist, but no loaded API token grants agent reporting scope.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(response as any);
|
||||
|
||||
await expect(UpdatesAPI.getUpdatePlan('v6.0.0')).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('rejects blank update-plan version before making a request', async () => {
|
||||
await expect(UpdatesAPI.getUpdatePlan(' ', 'stable')).rejects.toThrow('Version is required');
|
||||
expect(apiFetchJSONMock).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,23 @@ export interface VersionInfo {
|
|||
deploymentType?: string;
|
||||
}
|
||||
|
||||
export type UpdateReadinessStatus = 'ready' | 'attention' | 'blocked';
|
||||
export type UpdateReadinessCheckStatus = 'pass' | 'warning' | 'blocked';
|
||||
|
||||
export interface UpdateReadinessCheck {
|
||||
id: string;
|
||||
status: UpdateReadinessCheckStatus;
|
||||
title: string;
|
||||
summary: string;
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateReadiness {
|
||||
status: UpdateReadinessStatus;
|
||||
summary: string;
|
||||
checks: UpdateReadinessCheck[];
|
||||
}
|
||||
|
||||
export interface UpdatePlan {
|
||||
canAutoUpdate: boolean;
|
||||
instructions?: string[];
|
||||
|
|
@ -40,6 +57,7 @@ export interface UpdatePlan {
|
|||
requiresRoot: boolean;
|
||||
rollbackSupport: boolean;
|
||||
downloadUrl?: string;
|
||||
readiness?: UpdateReadiness;
|
||||
}
|
||||
|
||||
const requireNonEmpty = (value: string, fieldName: string): string => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { Component, For, Show } from 'solid-js';
|
||||
import Download from 'lucide-solid/icons/download';
|
||||
import type { UpdateInfo, UpdatePlan, VersionInfo } from '@/api/updates';
|
||||
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
|
||||
import CheckCircle from 'lucide-solid/icons/check-circle';
|
||||
import CircleAlert from 'lucide-solid/icons/circle-alert';
|
||||
import type {
|
||||
UpdateInfo,
|
||||
UpdatePlan,
|
||||
UpdateReadiness,
|
||||
UpdateReadinessCheck,
|
||||
VersionInfo,
|
||||
} from '@/api/updates';
|
||||
import { CopyCommandBlock } from '@/components/Settings/CopyCommandBlock';
|
||||
import {
|
||||
buildIdleDockerComposeCommand,
|
||||
|
|
@ -53,6 +62,71 @@ function InstallStep(props: { step: UpdateInstallStep; index: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
function readinessTone(status: UpdateReadiness['status']) {
|
||||
switch (status) {
|
||||
case 'blocked':
|
||||
return 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-100';
|
||||
case 'attention':
|
||||
return 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100';
|
||||
default:
|
||||
return 'border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950/40 dark:text-green-100';
|
||||
}
|
||||
}
|
||||
|
||||
function readinessCheckTone(status: UpdateReadinessCheck['status']) {
|
||||
switch (status) {
|
||||
case 'blocked':
|
||||
return 'text-red-700 dark:text-red-300';
|
||||
case 'warning':
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
default:
|
||||
return 'text-green-700 dark:text-green-300';
|
||||
}
|
||||
}
|
||||
|
||||
function ReadinessCheckIcon(props: { status: UpdateReadinessCheck['status'] }) {
|
||||
if (props.status === 'blocked') {
|
||||
return <CircleAlert class="h-4 w-4" />;
|
||||
}
|
||||
if (props.status === 'warning') {
|
||||
return <AlertTriangle class="h-4 w-4" />;
|
||||
}
|
||||
return <CheckCircle class="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function UpdateReadinessPanel(props: { readiness: UpdateReadiness }) {
|
||||
return (
|
||||
<section class={`rounded-md border p-4 ${readinessTone(props.readiness.status)}`}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm font-semibold">Upgrade checks</p>
|
||||
<p class="text-xs opacity-90">{props.readiness.summary}</p>
|
||||
</div>
|
||||
<div class="mt-3 divide-y divide-black/10 dark:divide-white/10">
|
||||
<For each={props.readiness.checks}>
|
||||
{(check) => (
|
||||
<div class="py-3 first:pt-0 last:pb-0">
|
||||
<div class={`flex items-start gap-2 ${readinessCheckTone(check.status)}`}>
|
||||
<span class="mt-0.5 flex-shrink-0">
|
||||
<ReadinessCheckIcon status={check.status} />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{check.title}</p>
|
||||
<p class="mt-0.5 text-xs opacity-90">{check.summary}</p>
|
||||
<Show when={check.details?.length}>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs opacity-85">
|
||||
<For each={check.details ?? []}>{(detail) => <li>{detail}</li>}</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const UpdateInstallGuide: Component<UpdateInstallGuideProps> = (props) => {
|
||||
const guide = () =>
|
||||
buildUpdateInstallGuide(
|
||||
|
|
@ -62,6 +136,11 @@ export const UpdateInstallGuide: Component<UpdateInstallGuideProps> = (props) =>
|
|||
props.dockerImageTag,
|
||||
props.systemdDownloadCommand,
|
||||
);
|
||||
const readinessBlocked = () => props.updatePlan?.readiness?.status === 'blocked';
|
||||
const introText = () =>
|
||||
readinessBlocked()
|
||||
? 'Resolve blocked upgrade checks before installing. Manual steps are listed for reference:'
|
||||
: guide()!.introText;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -142,20 +221,30 @@ export const UpdateInstallGuide: Component<UpdateInstallGuideProps> = (props) =>
|
|||
<button
|
||||
type="button"
|
||||
onClick={props.onInstallUpdate}
|
||||
disabled={props.isInstalling}
|
||||
disabled={props.isInstalling || readinessBlocked()}
|
||||
class={`flex w-full items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all sm:w-auto ${
|
||||
props.isInstalling
|
||||
? 'cursor-not-allowed bg-green-400 text-white dark:bg-green-600'
|
||||
: readinessBlocked()
|
||||
? 'cursor-not-allowed bg-red-200 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
<Show
|
||||
when={props.isInstalling}
|
||||
fallback={
|
||||
<>
|
||||
<Download class="h-4 w-4" />
|
||||
Install Update
|
||||
</>
|
||||
<Show
|
||||
when={readinessBlocked()}
|
||||
fallback={
|
||||
<>
|
||||
<Download class="h-4 w-4" />
|
||||
Install Update
|
||||
</>
|
||||
}
|
||||
>
|
||||
<CircleAlert class="h-4 w-4" />
|
||||
Install blocked
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
|
|
@ -167,7 +256,11 @@ export const UpdateInstallGuide: Component<UpdateInstallGuideProps> = (props) =>
|
|||
</div>
|
||||
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="mb-3 text-sm text-green-700 dark:text-green-300">{guide()!.introText}</div>
|
||||
<Show when={props.updatePlan?.readiness}>
|
||||
{(readiness) => <UpdateReadinessPanel readiness={readiness()} />}
|
||||
</Show>
|
||||
|
||||
<div class="mb-3 text-sm text-green-700 dark:text-green-300">{introText()}</div>
|
||||
|
||||
<For each={guide()!.steps}>
|
||||
{(step, index) => <InstallStep step={step} index={index() + 1} />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UpdateInstallGuide } from '../UpdateInstallGuide';
|
||||
|
||||
describe('UpdateInstallGuide', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows upgrade readiness checks from the update plan', () => {
|
||||
render(() => (
|
||||
<UpdateInstallGuide
|
||||
versionInfo={{
|
||||
version: 'v6.0.0-rc.6',
|
||||
build: 'release',
|
||||
runtime: 'go',
|
||||
isDocker: false,
|
||||
isSourceBuild: false,
|
||||
isDevelopment: false,
|
||||
deploymentType: 'systemd',
|
||||
}}
|
||||
updateInfo={{
|
||||
available: true,
|
||||
currentVersion: 'v6.0.0-rc.6',
|
||||
latestVersion: 'v6.0.0',
|
||||
releaseNotes: '',
|
||||
releaseDate: '2026-05-28T00:00:00Z',
|
||||
downloadUrl: 'https://example.invalid/pulse.tar.gz',
|
||||
isPrerelease: false,
|
||||
isMajorUpgrade: false,
|
||||
}}
|
||||
updatePlan={{
|
||||
canAutoUpdate: true,
|
||||
instructions: ['Install Pulse'],
|
||||
prerequisites: [],
|
||||
requiresRoot: true,
|
||||
rollbackSupport: true,
|
||||
readiness: {
|
||||
status: 'ready',
|
||||
summary: 'Upgrade checks passed for this Pulse instance.',
|
||||
checks: [
|
||||
{
|
||||
id: 'agent-continuity',
|
||||
status: 'pass',
|
||||
title: 'Agent continuity',
|
||||
summary: 'Registered agents have recent heartbeats.',
|
||||
details: ['1 v5 or legacy agent can continue reporting.'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
isInstalling={false}
|
||||
dockerImageTag="v6.0.0"
|
||||
systemdDownloadCommand="curl -fsSL https://example.invalid | tar"
|
||||
onInstallUpdate={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Upgrade checks')).toBeInTheDocument();
|
||||
expect(screen.getByText('Upgrade checks passed for this Pulse instance.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent continuity')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 v5 or legacy agent can continue reporting.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks the automatic install action when readiness is blocked', () => {
|
||||
const onInstallUpdate = vi.fn();
|
||||
render(() => (
|
||||
<UpdateInstallGuide
|
||||
versionInfo={{
|
||||
version: 'v6.0.0-rc.6',
|
||||
build: 'release',
|
||||
runtime: 'go',
|
||||
isDocker: false,
|
||||
isSourceBuild: false,
|
||||
isDevelopment: false,
|
||||
deploymentType: 'systemd',
|
||||
}}
|
||||
updateInfo={{
|
||||
available: true,
|
||||
currentVersion: 'v6.0.0-rc.6',
|
||||
latestVersion: 'v6.0.0',
|
||||
releaseNotes: '',
|
||||
releaseDate: '2026-05-28T00:00:00Z',
|
||||
downloadUrl: 'https://example.invalid/pulse.tar.gz',
|
||||
isPrerelease: false,
|
||||
isMajorUpgrade: false,
|
||||
}}
|
||||
updatePlan={{
|
||||
canAutoUpdate: true,
|
||||
instructions: ['Install Pulse'],
|
||||
prerequisites: [],
|
||||
requiresRoot: true,
|
||||
rollbackSupport: true,
|
||||
readiness: {
|
||||
status: 'blocked',
|
||||
summary: 'Resolve 1 blocked upgrade check before installing this update.',
|
||||
checks: [
|
||||
{
|
||||
id: 'agent-token-scopes',
|
||||
status: 'blocked',
|
||||
title: 'Agent token scopes',
|
||||
summary: 'Registered agents exist, but no loaded API token grants agent reporting scope.',
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
isInstalling={false}
|
||||
dockerImageTag="v6.0.0"
|
||||
systemdDownloadCommand="curl -fsSL https://example.invalid | tar"
|
||||
onInstallUpdate={onInstallUpdate}
|
||||
/>
|
||||
));
|
||||
|
||||
const installButton = screen.getByRole('button', { name: 'Install blocked' });
|
||||
expect(installButton).toBeDisabled();
|
||||
fireEvent.click(installButton);
|
||||
expect(onInstallUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@ import rbacFeatureGateSectionSource from '../RBACFeatureGateSection.tsx?raw';
|
|||
import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
|
||||
import systemLogsPanelSource from '../SystemLogsPanel.tsx?raw';
|
||||
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
|
||||
import updateInstallGuideSource from '../UpdateInstallGuide.tsx?raw';
|
||||
import agentProfilesPanelSource from '../AgentProfilesPanel.tsx?raw';
|
||||
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
|
||||
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
|
||||
|
|
@ -596,6 +597,13 @@ describe('settings architecture guardrails', () => {
|
|||
expect(aiSettingsStateSource).toContain('getAISettingsSaveProviderFailureMessage(');
|
||||
});
|
||||
|
||||
it('keeps update readiness checks on the shared install guide boundary', () => {
|
||||
expect(updateInstallGuideSource).toContain('UpdateReadinessPanel');
|
||||
expect(updateInstallGuideSource).toContain("props.updatePlan?.readiness?.status === 'blocked'");
|
||||
expect(updateInstallGuideSource).toContain('Install blocked');
|
||||
expect(updateInstallGuideSource).toContain('disabled={props.isInstalling || readinessBlocked()}');
|
||||
});
|
||||
|
||||
it('keeps infrastructure on a source-manager landing with route-backed dialogs', () => {
|
||||
expect(infrastructureWorkspaceSource).toContain(
|
||||
"import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';",
|
||||
|
|
|
|||
|
|
@ -465,6 +465,15 @@ func (r *Router) setupRoutes() {
|
|||
r.vmwarePoller = monitoring.NewVMwarePoller(r.multiTenant, 0)
|
||||
r.vmwarePoller.Start(r.lifecycleCtx)
|
||||
updateHandlers := NewUpdateHandlersWithContext(r.updateManager, r.updateHistory, r.lifecycleCtx)
|
||||
updateHandlers.SetUpdateReadinessSources(
|
||||
r.configHandlers.getConfig,
|
||||
func(context.Context) []models.Host {
|
||||
if r.monitor == nil {
|
||||
return nil
|
||||
}
|
||||
return r.monitor.HostsSnapshot()
|
||||
},
|
||||
)
|
||||
r.dockerAgentHandlers = NewDockerAgentHandlers(r.mtMonitor, r.monitor, r.wsHub, r.config)
|
||||
r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.mtMonitor, r.monitor, r.wsHub)
|
||||
r.unifiedAgentHandlers = NewUnifiedAgentHandlers(r.mtMonitor, r.monitor, r.wsHub)
|
||||
|
|
|
|||
310
internal/api/update_readiness.go
Normal file
310
internal/api/update_readiness.go
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
)
|
||||
|
||||
const (
|
||||
updateReadinessReady = "ready"
|
||||
updateReadinessAttention = "attention"
|
||||
updateReadinessBlocked = "blocked"
|
||||
|
||||
updateReadinessCheckPass = "pass"
|
||||
updateReadinessCheckWarning = "warning"
|
||||
updateReadinessCheckBlocked = "blocked"
|
||||
)
|
||||
|
||||
type updateReadinessInputs struct {
|
||||
cfg *config.Config
|
||||
hosts []models.Host
|
||||
targetVersion string
|
||||
plan updates.UpdatePlan
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func buildUpdateReadiness(in updateReadinessInputs) *updates.UpdateReadiness {
|
||||
now := in.now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
checks := []updates.UpdateReadinessCheck{
|
||||
buildUpdatePathReadinessCheck(in.plan),
|
||||
buildAgentContinuityReadinessCheck(in.hosts, in.targetVersion, now),
|
||||
buildAgentTokenReadinessCheck(in.cfg, len(in.hosts), now),
|
||||
}
|
||||
|
||||
status := updateReadinessReady
|
||||
for _, check := range checks {
|
||||
switch check.Status {
|
||||
case updateReadinessCheckBlocked:
|
||||
status = updateReadinessBlocked
|
||||
case updateReadinessCheckWarning:
|
||||
if status != updateReadinessBlocked {
|
||||
status = updateReadinessAttention
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &updates.UpdateReadiness{
|
||||
Status: status,
|
||||
Summary: updateReadinessSummary(status, checks),
|
||||
Checks: checks,
|
||||
}
|
||||
}
|
||||
|
||||
func updateReadinessSummary(status string, checks []updates.UpdateReadinessCheck) string {
|
||||
blocked := 0
|
||||
warnings := 0
|
||||
for _, check := range checks {
|
||||
switch check.Status {
|
||||
case updateReadinessCheckBlocked:
|
||||
blocked++
|
||||
case updateReadinessCheckWarning:
|
||||
warnings++
|
||||
}
|
||||
}
|
||||
|
||||
switch status {
|
||||
case updateReadinessBlocked:
|
||||
return fmt.Sprintf("Resolve %d blocked upgrade %s before installing this update.", blocked, pluralNoun(blocked, "check", "checks"))
|
||||
case updateReadinessAttention:
|
||||
return fmt.Sprintf("Update can proceed, but review %d upgrade %s before installing.", warnings, pluralNoun(warnings, "warning", "warnings"))
|
||||
default:
|
||||
return "Upgrade checks passed for this Pulse instance."
|
||||
}
|
||||
}
|
||||
|
||||
func buildUpdatePathReadinessCheck(plan updates.UpdatePlan) updates.UpdateReadinessCheck {
|
||||
if plan.CanAutoUpdate && plan.RollbackSupport {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "server-update-path",
|
||||
Status: updateReadinessCheckPass,
|
||||
Title: "Server update path",
|
||||
Summary: "Automatic install and rollback support are available for this deployment.",
|
||||
Details: []string{
|
||||
"Pulse will stage the update through the configured updater.",
|
||||
"A rollback-capable backup is part of this update path.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if plan.CanAutoUpdate {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "server-update-path",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Server update path",
|
||||
Summary: "Automatic install is available, but rollback support is not advertised by this updater.",
|
||||
Details: []string{"Keep console access and an external backup available before installing."},
|
||||
}
|
||||
}
|
||||
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "server-update-path",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Server update path",
|
||||
Summary: "This deployment uses a manual update path.",
|
||||
Details: []string{"Follow the generated update instructions and keep a backup available before restarting Pulse."},
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgentContinuityReadinessCheck(hosts []models.Host, targetVersion string, now time.Time) updates.UpdateReadinessCheck {
|
||||
if len(hosts) == 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-continuity",
|
||||
Status: updateReadinessCheckPass,
|
||||
Title: "Agent continuity",
|
||||
Summary: "No installed Pulse agents are currently registered.",
|
||||
}
|
||||
}
|
||||
|
||||
connections := buildConnections(aggregatorInputs{
|
||||
hosts: hosts,
|
||||
expectedAgentVersion: strings.TrimSpace(targetVersion),
|
||||
now: now,
|
||||
})
|
||||
|
||||
active := 0
|
||||
pendingOrStale := 0
|
||||
behind := 0
|
||||
legacy := 0
|
||||
unknownVersion := 0
|
||||
for _, conn := range connections {
|
||||
if conn.Type != ConnectionTypeAgent {
|
||||
continue
|
||||
}
|
||||
switch conn.State {
|
||||
case ConnectionStateActive:
|
||||
active++
|
||||
case ConnectionStatePending, ConnectionStateStale:
|
||||
pendingOrStale++
|
||||
}
|
||||
if conn.AgentUpdateAvailable {
|
||||
behind++
|
||||
}
|
||||
if strings.TrimSpace(conn.AgentVersion) == "" {
|
||||
unknownVersion++
|
||||
}
|
||||
}
|
||||
for _, host := range hosts {
|
||||
if host.IsLegacy || looksLikePreV6Version(host.AgentVersion) {
|
||||
legacy++
|
||||
}
|
||||
}
|
||||
|
||||
details := []string{fmt.Sprintf("%s currently registered.", countWithNoun(len(hosts), "agent", "agents"))}
|
||||
if active > 0 {
|
||||
details = append(details, fmt.Sprintf("%s have a recent heartbeat.", countWithNoun(active, "agent", "agents")))
|
||||
}
|
||||
if legacy > 0 {
|
||||
details = append(details, fmt.Sprintf("%s can continue reporting through the v6 compatibility route.", countWithNoun(legacy, "v5 or legacy agent", "v5 or legacy agents")))
|
||||
}
|
||||
if behind > 0 {
|
||||
details = append(details, fmt.Sprintf("%s should move toward %s after the server update.", countWithNoun(behind, "agent", "agents"), targetVersionLabel(targetVersion)))
|
||||
}
|
||||
if unknownVersion > 0 {
|
||||
details = append(details, fmt.Sprintf("%s did not report a version.", countWithNoun(unknownVersion, "agent", "agents")))
|
||||
}
|
||||
|
||||
if pendingOrStale > 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-continuity",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Agent continuity",
|
||||
Summary: fmt.Sprintf("%s %s not recently connected, so Pulse cannot prove they will update cleanly.", countWithNoun(pendingOrStale, "agent", "agents"), pluralVerb(pendingOrStale, "is", "are")),
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
if unknownVersion > 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-continuity",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Agent continuity",
|
||||
Summary: "Some agents are connected but did not report a version.",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-continuity",
|
||||
Status: updateReadinessCheckPass,
|
||||
Title: "Agent continuity",
|
||||
Summary: "Registered agents have recent heartbeats and can continue through the v6 compatibility path.",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAgentTokenReadinessCheck(cfg *config.Config, agentCount int, now time.Time) updates.UpdateReadinessCheck {
|
||||
if cfg == nil {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "Pulse could not read the loaded API token set for this readiness check.",
|
||||
}
|
||||
}
|
||||
|
||||
if agentCount == 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckPass,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "No registered agents need an agent reporting token during this upgrade.",
|
||||
}
|
||||
}
|
||||
|
||||
agentScoped := 0
|
||||
expired := 0
|
||||
expiring := 0
|
||||
for _, record := range cfg.APITokens {
|
||||
if !record.HasScope(config.ScopeAgentReport) {
|
||||
continue
|
||||
}
|
||||
agentScoped++
|
||||
if record.ExpiresAt != nil {
|
||||
if now.After(*record.ExpiresAt) {
|
||||
expired++
|
||||
} else if record.ExpiresAt.Sub(now) <= 14*24*time.Hour {
|
||||
expiring++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if agentScoped == 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckBlocked,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "Registered agents exist, but no loaded API token grants agent reporting scope.",
|
||||
Details: []string{"Existing v5 host-agent scopes are normally normalized to agent:report on load."},
|
||||
}
|
||||
}
|
||||
if expired >= agentScoped {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckBlocked,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "All loaded agent reporting tokens are expired.",
|
||||
Details: []string{"Create or refresh an agent install token before relying on agent reconnect after the update."},
|
||||
}
|
||||
}
|
||||
if expired > 0 || expiring > 0 {
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckWarning,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "At least one agent reporting token is expired or expires soon.",
|
||||
Details: []string{
|
||||
fmt.Sprintf("%s loaded.", countWithNoun(agentScoped, "agent reporting token", "agent reporting tokens")),
|
||||
fmt.Sprintf("%s expired, %s expiring within 14 days.", countWithNoun(expired, "token", "tokens"), countWithNoun(expiring, "token", "tokens")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return updates.UpdateReadinessCheck{
|
||||
ID: "agent-token-scopes",
|
||||
Status: updateReadinessCheckPass,
|
||||
Title: "Agent token scopes",
|
||||
Summary: "Loaded API tokens include agent reporting scope for registered agents.",
|
||||
Details: []string{fmt.Sprintf("%s available.", countWithNoun(agentScoped, "agent reporting token", "agent reporting tokens"))},
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikePreV6Version(version string) bool {
|
||||
version = strings.TrimSpace(strings.TrimPrefix(version, "v"))
|
||||
if version == "" {
|
||||
return false
|
||||
}
|
||||
major, _, ok := strings.Cut(version, ".")
|
||||
if !ok || major == "" {
|
||||
return false
|
||||
}
|
||||
majorNumber, err := strconv.Atoi(major)
|
||||
return err == nil && majorNumber < 6
|
||||
}
|
||||
|
||||
func targetVersionLabel(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return "the target server version"
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func pluralNoun(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return singular
|
||||
}
|
||||
return plural
|
||||
}
|
||||
|
||||
func countWithNoun(count int, singular, plural string) string {
|
||||
return fmt.Sprintf("%d %s", count, pluralNoun(count, singular, plural))
|
||||
}
|
||||
106
internal/api/update_readiness_test.go
Normal file
106
internal/api/update_readiness_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
)
|
||||
|
||||
func TestBuildUpdateReadiness_ActiveV5AgentIsReady(t *testing.T) {
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
record, err := config.NewAPITokenRecord("abcdef1234567890abcdef1234567890", "agent", []string{config.ScopeAgentReport})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord: %v", err)
|
||||
}
|
||||
|
||||
readiness := buildUpdateReadiness(updateReadinessInputs{
|
||||
cfg: &config.Config{APITokens: []config.APITokenRecord{*record}},
|
||||
hosts: []models.Host{{
|
||||
ID: "host-1",
|
||||
Hostname: "host-1",
|
||||
LastSeen: now.Add(-30 * time.Second),
|
||||
AgentVersion: "5.1.23",
|
||||
IsLegacy: true,
|
||||
}},
|
||||
targetVersion: "v6.0.0",
|
||||
plan: updates.UpdatePlan{
|
||||
CanAutoUpdate: true,
|
||||
RollbackSupport: true,
|
||||
},
|
||||
now: now,
|
||||
})
|
||||
|
||||
if readiness.Status != updateReadinessReady {
|
||||
t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessReady, readiness)
|
||||
}
|
||||
for _, check := range readiness.Checks {
|
||||
if check.Status != updateReadinessCheckPass {
|
||||
t.Fatalf("check %s status = %q, want pass", check.ID, check.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateReadiness_BlocksWhenAgentsHaveNoReportingToken(t *testing.T) {
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
record, err := config.NewAPITokenRecord("abcdef1234567890abcdef1234567890", "settings", []string{config.ScopeSettingsRead})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord: %v", err)
|
||||
}
|
||||
|
||||
readiness := buildUpdateReadiness(updateReadinessInputs{
|
||||
cfg: &config.Config{APITokens: []config.APITokenRecord{*record}},
|
||||
hosts: []models.Host{{
|
||||
ID: "host-1",
|
||||
Hostname: "host-1",
|
||||
LastSeen: now.Add(-30 * time.Second),
|
||||
AgentVersion: "6.0.0-rc.6",
|
||||
}},
|
||||
targetVersion: "v6.0.0",
|
||||
plan: updates.UpdatePlan{
|
||||
CanAutoUpdate: true,
|
||||
RollbackSupport: true,
|
||||
},
|
||||
now: now,
|
||||
})
|
||||
|
||||
if readiness.Status != updateReadinessBlocked {
|
||||
t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessBlocked, readiness)
|
||||
}
|
||||
if got := readiness.Checks[2].Status; got != updateReadinessCheckBlocked {
|
||||
t.Fatalf("agent token check status = %q, want blocked", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateReadiness_WarnsOnStaleAgent(t *testing.T) {
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
record, err := config.NewAPITokenRecord("abcdef1234567890abcdef1234567890", "agent", []string{config.ScopeAgentReport})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord: %v", err)
|
||||
}
|
||||
|
||||
readiness := buildUpdateReadiness(updateReadinessInputs{
|
||||
cfg: &config.Config{APITokens: []config.APITokenRecord{*record}},
|
||||
hosts: []models.Host{{
|
||||
ID: "host-1",
|
||||
Hostname: "host-1",
|
||||
LastSeen: now.Add(-5 * time.Minute),
|
||||
AgentVersion: "6.0.0-rc.6",
|
||||
}},
|
||||
targetVersion: "v6.0.0",
|
||||
plan: updates.UpdatePlan{
|
||||
CanAutoUpdate: true,
|
||||
RollbackSupport: true,
|
||||
},
|
||||
now: now,
|
||||
})
|
||||
|
||||
if readiness.Status != updateReadinessAttention {
|
||||
t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessAttention, readiness)
|
||||
}
|
||||
if got := readiness.Checks[1].Status; got != updateReadinessCheckWarning {
|
||||
t.Fatalf("agent continuity check status = %q, want warning", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mockmode"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -26,6 +27,9 @@ type UpdateHandlers struct {
|
|||
registry *updates.UpdaterRegistry
|
||||
statusRateLimits map[string]time.Time // IP -> last request time
|
||||
statusMu sync.RWMutex
|
||||
getConfig func(context.Context) *config.Config
|
||||
getHostsSnapshot func(context.Context) []models.Host
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// UpdateManager defines the interface for update management operations
|
||||
|
|
@ -66,6 +70,7 @@ func NewUpdateHandlersWithContext(manager UpdateManager, history *updates.Update
|
|||
history: history,
|
||||
registry: registry,
|
||||
statusRateLimits: make(map[string]time.Time),
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
// Start periodic cleanup of rate limit map
|
||||
|
|
@ -74,6 +79,19 @@ func NewUpdateHandlersWithContext(manager UpdateManager, history *updates.Update
|
|||
return h
|
||||
}
|
||||
|
||||
// SetUpdateReadinessSources wires runtime state used to attach an upgrade
|
||||
// readiness verdict to update plans. Nil callbacks leave plans unchanged.
|
||||
func (h *UpdateHandlers) SetUpdateReadinessSources(
|
||||
getConfig func(context.Context) *config.Config,
|
||||
getHostsSnapshot func(context.Context) []models.Host,
|
||||
) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.getConfig = getConfig
|
||||
h.getHostsSnapshot = getHostsSnapshot
|
||||
}
|
||||
|
||||
// HandleCheckUpdates handles update check requests
|
||||
func (h *UpdateHandlers) HandleCheckUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
|
@ -378,7 +396,7 @@ func (h *UpdateHandlers) HandleGetUpdatePlan(w http.ResponseWriter, r *http.Requ
|
|||
if err != nil {
|
||||
if plan, ok := fallbackManualUpdatePlan(versionInfo.DeploymentType, version); ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
normalizedPlan := plan.NormalizeCollections()
|
||||
normalizedPlan := h.attachUpdateReadiness(r.Context(), version, plan.NormalizeCollections())
|
||||
json.NewEncoder(w).Encode(normalizedPlan)
|
||||
return
|
||||
}
|
||||
|
|
@ -399,10 +417,28 @@ func (h *UpdateHandlers) HandleGetUpdatePlan(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
normalizedPlan := plan.NormalizeCollections()
|
||||
normalizedPlan := h.attachUpdateReadiness(r.Context(), version, plan.NormalizeCollections())
|
||||
json.NewEncoder(w).Encode(normalizedPlan)
|
||||
}
|
||||
|
||||
func (h *UpdateHandlers) attachUpdateReadiness(ctx context.Context, version string, plan updates.UpdatePlan) updates.UpdatePlan {
|
||||
if h == nil || h.getConfig == nil || h.getHostsSnapshot == nil {
|
||||
return plan
|
||||
}
|
||||
now := time.Now()
|
||||
if h.now != nil {
|
||||
now = h.now()
|
||||
}
|
||||
plan.Readiness = buildUpdateReadiness(updateReadinessInputs{
|
||||
cfg: h.getConfig(ctx),
|
||||
hosts: h.getHostsSnapshot(ctx),
|
||||
targetVersion: version,
|
||||
plan: plan,
|
||||
now: now,
|
||||
})
|
||||
return plan.NormalizeCollections()
|
||||
}
|
||||
|
||||
func fallbackManualUpdatePlan(deploymentType string, version string) (*updates.UpdatePlan, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(deploymentType))
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
||||
)
|
||||
|
||||
|
|
@ -582,6 +584,65 @@ func TestHandleGetUpdatePlan(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleGetUpdatePlan_IncludesUpgradeReadiness(t *testing.T) {
|
||||
setMockModeForTest(t, true)
|
||||
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
rawToken := "abcdef1234567890abcdef1234567890"
|
||||
record, err := config.NewAPITokenRecord(rawToken, "agent", []string{config.ScopeAgentReport})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord: %v", err)
|
||||
}
|
||||
|
||||
h := NewUpdateHandlers(nil, nil)
|
||||
h.now = func() time.Time { return now }
|
||||
h.SetUpdateReadinessSources(
|
||||
func(context.Context) *config.Config {
|
||||
return &config.Config{APITokens: []config.APITokenRecord{*record}}
|
||||
},
|
||||
func(context.Context) []models.Host {
|
||||
return []models.Host{{
|
||||
ID: "host-1",
|
||||
Hostname: "host-1",
|
||||
LastSeen: now.Add(-30 * time.Second),
|
||||
AgentVersion: "5.1.23",
|
||||
IsLegacy: true,
|
||||
}}
|
||||
},
|
||||
)
|
||||
h.registry.Register("mock", &mockUpdater{
|
||||
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
|
||||
return &updates.UpdatePlan{
|
||||
CanAutoUpdate: true,
|
||||
RollbackSupport: true,
|
||||
Instructions: []string{"install"},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/updates/plan?version=v6.0.0", nil)
|
||||
h.HandleGetUpdatePlan(w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var plan updates.UpdatePlan
|
||||
if err := json.NewDecoder(w.Body).Decode(&plan); err != nil {
|
||||
t.Fatalf("decode plan: %v", err)
|
||||
}
|
||||
if plan.Readiness == nil {
|
||||
t.Fatal("expected readiness on update plan")
|
||||
}
|
||||
if plan.Readiness.Status != "ready" {
|
||||
t.Fatalf("readiness status = %q, want ready: %#v", plan.Readiness.Status, plan.Readiness)
|
||||
}
|
||||
if len(plan.Readiness.Checks) != 3 {
|
||||
t.Fatalf("readiness checks = %d, want 3", len(plan.Readiness.Checks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetUpdatePlan_InvalidChannel(t *testing.T) {
|
||||
setMockModeForTest(t, true)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,32 @@ type UpdateRequest struct {
|
|||
|
||||
// UpdatePlan contains information about how an update will be performed
|
||||
type UpdatePlan struct {
|
||||
CanAutoUpdate bool `json:"canAutoUpdate"`
|
||||
Instructions []string `json:"instructions"`
|
||||
Prerequisites []string `json:"prerequisites"`
|
||||
EstimatedTime string `json:"estimatedTime,omitempty"`
|
||||
RequiresRoot bool `json:"requiresRoot"`
|
||||
RollbackSupport bool `json:"rollbackSupport"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
CanAutoUpdate bool `json:"canAutoUpdate"`
|
||||
Instructions []string `json:"instructions"`
|
||||
Prerequisites []string `json:"prerequisites"`
|
||||
EstimatedTime string `json:"estimatedTime,omitempty"`
|
||||
RequiresRoot bool `json:"requiresRoot"`
|
||||
RollbackSupport bool `json:"rollbackSupport"`
|
||||
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||
Readiness *UpdateReadiness `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateReadiness is the operator-facing preflight summary attached to an
|
||||
// update plan. It is advisory unless a check is explicitly "blocked".
|
||||
type UpdateReadiness struct {
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Checks []UpdateReadinessCheck `json:"checks"`
|
||||
}
|
||||
|
||||
// UpdateReadinessCheck is one concrete compatibility or continuity check for
|
||||
// the requested server update.
|
||||
type UpdateReadinessCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func EmptyUpdatePlan() UpdatePlan {
|
||||
|
|
@ -35,6 +54,9 @@ func (p UpdatePlan) NormalizeCollections() UpdatePlan {
|
|||
if p.Prerequisites == nil {
|
||||
p.Prerequisites = []string{}
|
||||
}
|
||||
if p.Readiness != nil && p.Readiness.Checks == nil {
|
||||
p.Readiness.Checks = []UpdateReadinessCheck{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -199,6 +199,22 @@ func TestUpdatePlan_UsesCanonicalEmptyCollections(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUpdatePlan_NormalizeCollectionsInitializesReadinessChecks(t *testing.T) {
|
||||
plan := UpdatePlan{
|
||||
Readiness: &UpdateReadiness{
|
||||
Status: "ready",
|
||||
Summary: "Upgrade checks passed.",
|
||||
},
|
||||
}.NormalizeCollections()
|
||||
|
||||
if plan.Readiness == nil {
|
||||
t.Fatal("expected readiness to remain attached")
|
||||
}
|
||||
if plan.Readiness.Checks == nil {
|
||||
t.Fatal("expected readiness checks to normalize to an empty slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProgress_Fields(t *testing.T) {
|
||||
progress := UpdateProgress{
|
||||
Stage: "downloading",
|
||||
|
|
|
|||
|
|
@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 278,
|
||||
"line": 281,
|
||||
"heading_line": 112,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue