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:
rcourtman 2026-05-28 16:47:49 +01:00
parent 94992685d1
commit e1d1ec1d7c
21 changed files with 887 additions and 17 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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';",

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
}
],