Block silent downgrades on apply and give the updater a sanctioned rollback path

The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.

The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.

Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.

Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
This commit is contained in:
rcourtman
2026-07-10 01:46:08 +01:00
parent eb63de0b75
commit a0170d3252
23 changed files with 1434 additions and 17 deletions
@@ -160,7 +160,11 @@ off it. The same boundary covers the server updater's deployment adapters
behind `GET /api/updates/plan` in `internal/api/updates.go`: they are plan
providers only, with the real apply in the `internal/updates/manager.go`
pipeline, and lifecycle surfaces must not read them as an agent-side apply,
update, or rollback transport. Workflow starter counts on that endpoint,
update, or rollback transport. The server updater's own downgrade guard and
`POST /api/updates/rollback` backup-restore endpoint are equally server
self-update plumbing: they roll the Pulse server binary and its local
backups, never agent binaries, and agent lifecycle surfaces must not key
enrollment, update liveness, or fleet-control semantics off them. Workflow starter counts on that endpoint,
contextual Assistant/external-agent collaboration counts inside the Assistant
step, the content-free Patrol control starter split, and Patrol control
completed-loop, resolved-loop, or `patrolControlValueState` proof mirrored to
@@ -4319,6 +4319,11 @@ The retired monitored-system capacity banner follows the same shell rule:
volume warnings just because settings or support surfaces still expose
monitored-system grouping data. Assistant state and shell notices stay
independent from retired infrastructure-volume commerce.
The global update progress watcher in `frontend-modern/src/App.tsx` is
likewise server-updater shell chrome, not assistant surface: its in-progress
stage vocabulary mirrors the backend updater pipeline (including the
`restoring` stage emitted by update rollback), and assistant state, drawer
ownership, and AI runtime surfaces must not key off those update stages.
That same shared shell boundary must respect blocking modal ownership.
`frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` may use
the shared dialog runtime to hide the closed assistant launcher and close the
@@ -1589,8 +1589,19 @@ payload shape change when the portal presents compact client rows.
The updater registry behind `GET /api/updates/plan` is a plan-provider
seam only (`SupportsApply`, `PrepareUpdate`, `GetDeploymentType`); apply
and rollback semantics ride the manager pipeline behind
`POST /api/updates/apply`, and the updates API exposes no per-deployment
adapter execute or rollback transport.
`POST /api/updates/apply` and `POST /api/updates/rollback`, and the
updates API exposes no per-deployment adapter execute or rollback
transport.
`POST /api/updates/apply` rejects target versions at or below the running
version with a conflict response unless the request body sets the
explicit `allowDowngrade` flag, so a stale-but-valid release asset URL is
a refused downgrade rather than a silent install.
`POST /api/updates/rollback` takes the `eventId` of an update history
entry, is gated admin plus `settings:write` exactly like apply, restores
the retained backup recorded on that entry through the manager pipeline,
and answers with the same started-acknowledgement shape as apply;
conflict responses cover pruned or missing backups and Docker
deployments, and not-found covers unknown history entries.
85. `pkg/aicontracts/fix_execution.go` shared with `ai-runtime`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
86. `pkg/aicontracts/investigation.go` shared with `ai-runtime`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
87. `pkg/aicontracts/orchestrator_deps.go` shared with `ai-runtime`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
@@ -2081,6 +2081,12 @@ theme synchronization, and authenticated runtime startup, and
as org switching and kiosk-safe navigation. Future hosted browser bootstrap
work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
The global update progress watcher inside that entry shell is self-hosted
server-update chrome: its stage vocabulary tracks the backend updater
pipeline (downloading through restarting, including the `restoring` stage
emitted by update rollback) and it carries no hosted entitlement, org, or
paid-gating semantics; hosted and paid surfaces must not key tenant or
billing behavior off update progress stages.
That same authenticated shell split must also respect shared blocking dialogs:
hosted chrome may not leave the Pulse Assistant launcher or an already-open
assistant drawer interactive behind a modal that currently owns the viewport.
@@ -2278,3 +2278,37 @@ for that volume before launching the wrapper, recover the same state during
uninstall, and keep the persisted boot copy aligned with updater-owned runtime
binary replacements instead of assuming `/usr/local/bin` survives reboot on
QTS/QuTS hero.
The in-app updater's apply pipeline now owns a downgrade guard on the normal
apply path. A syntactically valid release asset URL can name a release older
than the running binary, so `internal/updates/manager.go` `ApplyUpdate` must
reject any resolved target version at or below the running version, on both
the community release-asset path and the Pro broker path, before any history
entry is written or byte is downloaded. Sanctioned downgrades are an explicit
opt-in through the `AllowDowngrade` request flag carried by
`POST /api/updates/apply`, never a silent side effect of a stale download
URL. The guard fails open only for versions that do not parse as semver
(development builds), which stay covered by the existing URL and channel
validation. `internal/updates/manager_rollback_test.go` and the apply handler
tests in `internal/api/updates_test.go` are the direct proof surface for this
rule.
That same updater boundary now also owns the sanctioned rollback path from
retained update backups. `RollbackToBackup` in `internal/updates/manager.go`
restores the backup directory recorded on an update history entry after
re-validating that the path still names a managed update backup on disk,
shares the single update-in-flight slot with `ApplyUpdate`, records the
rollback as its own history entry with `Action` `rollback` and a
`RelatedEventID` back to the rolled-back update, marks that source entry
`rolled_back`, streams progress through the existing update status and SSE
machinery as the `restoring` stage, and restarts through the same
exit-for-systemd path as a normal update. The transport surface is
`POST /api/updates/rollback`, admin plus `settings:write` gated exactly like
apply, and the Settings update history table in
`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` is the
user-facing rollback surface. Rollback is a purely local restore: it must not
touch the Pro download broker or any edition gate, so it behaves identically
on community and Pro binaries. The rollback tests in
`internal/updates/manager_rollback_test.go`, the rollback handler tests in
`internal/api/updates_test.go`, and the route inventory pin for
`/api/updates/rollback` are the proof surface for this path.
@@ -3873,7 +3873,13 @@ top-level settings shell, while
`frontend-modern/src/components/Settings/updatesSettingsModel.ts` plus
`frontend-modern/src/utils/updatesPresentation.ts` own the
deployment-specific install guide, copy-command block, and update-channel/install
model data plus customer-facing update status/action copy. The panel shell must
model data plus customer-facing update status/action copy.
`frontend-modern/src/components/Settings/UpdateHistorySection.tsx` joins that
split as the presentation owner for the update history table and the
rollback confirmation dialog: the panel shell mounts it as a section and must
not inline history rows, rollback gating, or rollback confirmation copy
itself, and the section starts rollbacks through the shared
`updateStore.rollbackUpdate` action rather than its own POST path. The panel shell must
not rebuild copy-to-clipboard command cards, deployment instruction trees, or
update-surface wording inline. `CopyCommandBlock` must use the shared
`copyToClipboard` helper so install/update/agent snippets keep the same
@@ -548,7 +548,12 @@ recovery scope, or a storage/recovery-owned secret source.
so recovery semantics never fork by edition. That machinery lives solely
in the `internal/updates/manager.go` apply pipeline; the deployment
adapters behind the update-plan endpoint are plan providers only and own
no download, restore, or rollback path.
no download, restore, or rollback path. The sanctioned rollback behind
`POST /api/updates/rollback` restores the updater's own retained
pre-update backup (binary, data, config, env, VERSION) through that same
manager pipeline; it is server self-update recovery, purely local with no
broker or edition fork, and storage/recovery surfaces must not read it as
guest backup, datastore restore, or recovery-point coverage.
Proxmox-side LXC Docker inventory wiring may also pass through
`internal/api/router.go` and Proxmox agent install-command generation, but
storage and recovery may consume the resulting app-container/resource
+11 -1
View File
@@ -140,9 +140,19 @@ async function preloadAppShellRoutes() {
}
// Helper to detect if an update is actively in progress (not just checking for updates)
// Mirrors the stage names emitted by internal/updates/manager.go updateStatus:
// the apply pipeline plus 'restoring' from the rollback path.
function isUpdateInProgress(status: string | undefined): boolean {
if (!status) return false;
const inProgressStates = ['downloading', 'verifying', 'extracting', 'installing', 'restarting'];
const inProgressStates = [
'downloading',
'verifying',
'extracting',
'backing-up',
'applying',
'restoring',
'restarting',
];
return inProgressStates.includes(status);
}
@@ -212,6 +212,23 @@ describe('App architecture', () => {
expect(appSource).not.toContain('monitoredSystemLimitWarningBanner');
});
it('keeps the update progress watcher aligned with the backend updater stages', () => {
// The in-progress stage list must mirror internal/updates/manager.go
// updateStatus emissions, including the rollback path's restoring stage,
// so the progress modal auto-opens for every real update or rollback.
expect(appSource).toContain("'downloading',");
expect(appSource).toContain("'verifying',");
expect(appSource).toContain("'extracting',");
expect(appSource).toContain("'backing-up',");
expect(appSource).toContain("'applying',");
expect(appSource).toContain("'restoring',");
expect(appSource).toContain("'restarting',");
// 'checking' is a probe, not an apply: it must never pop the modal, and
// the never-emitted legacy 'installing' stage must not return.
expect(appSource).not.toContain("'checking',");
expect(appSource).not.toContain("'installing'");
});
it('keeps integration browser proofs off the retired AI route', () => {
const routesSource = readFileSync(join(integrationTestsDir, 'routes.ts'), 'utf8');
const retiredRouteNavigations = readIntegrationTestSources(integrationTestsDir).flatMap(
@@ -61,6 +61,28 @@ describe('UpdatesAPI', () => {
});
});
it('rejects empty rollback event ID before making a request', async () => {
await expect(UpdatesAPI.rollbackUpdate(' ')).rejects.toThrow('Event ID is required');
expect(apiFetchJSONMock).not.toHaveBeenCalled();
});
it('trims event ID before rollback request', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ status: 'started', message: 'ok' } as any);
await UpdatesAPI.rollbackUpdate(' 01JZEXAMPLE ');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/updates/rollback', {
method: 'POST',
body: JSON.stringify({ eventId: '01JZEXAMPLE' }),
});
});
it('encodes the update-history limit', async () => {
apiFetchJSONMock.mockResolvedValueOnce([] as any);
await UpdatesAPI.listUpdateHistory(5);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/updates/history?limit=5');
});
it('encodes update-plan version and channel safely', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ canAutoUpdate: true } as any);
await UpdatesAPI.getUpdatePlan('v1.2.3-rc.1+build', 'rc');
+39
View File
@@ -61,6 +61,32 @@ export interface UpdatePlan {
readiness?: UpdateReadiness;
}
export interface UpdateHistoryEntryError {
message: string;
code?: string;
details?: string;
}
export interface UpdateHistoryEntry {
event_id: string;
timestamp: string;
action: string;
channel: string;
version_from: string;
version_to: string;
deployment_type: string;
initiated_by: string;
initiated_via: string;
status: string;
duration_ms: number;
backup_path?: string;
log_path?: string;
error?: UpdateHistoryEntryError;
download_bytes?: number;
related_event_id?: string;
notes?: string;
}
const requireNonEmpty = (value: string, fieldName: string): string => {
const trimmed = value.trim();
if (!trimmed) {
@@ -89,6 +115,19 @@ export class UpdatesAPI {
});
}
static async rollbackUpdate(eventId: string): Promise<{ status: string; message: string }> {
const normalizedEventId = requireNonEmpty(eventId, 'Event ID');
return apiFetchJSON('/api/updates/rollback', {
method: 'POST',
body: JSON.stringify({ eventId: normalizedEventId }),
});
}
static async listUpdateHistory(limit = 20): Promise<UpdateHistoryEntry[]> {
const search = new URLSearchParams({ limit: String(limit) });
return apiFetchJSON(`/api/updates/history?${search.toString()}`);
}
static async getUpdateStatus(): Promise<UpdateStatus> {
return apiFetchJSON('/api/updates/status');
}
@@ -0,0 +1,232 @@
import { Component, For, Show, createResource, createSignal } from 'solid-js';
import HistoryIcon from 'lucide-solid/icons/history';
import { UpdatesAPI, type UpdateHistoryEntry } from '@/api/updates';
import { updateStore } from '@/stores/updates';
import { Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
const HISTORY_LIMIT = 20;
const formatVersionLabel = (version: string): string => {
const trimmed = version.trim();
if (!trimmed) return 'unknown';
return trimmed.startsWith('v') ? trimmed : `v${trimmed}`;
};
const formatTimestamp = (timestamp: string): string => {
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleString();
};
const actionLabel = (entry: UpdateHistoryEntry): string => {
switch (entry.action) {
case 'rollback':
return 'Rollback';
default:
return entry.initiated_by === 'auto' ? 'Automatic update' : 'Update';
}
};
const statusPresentation = (status: string): { label: string; className: string } => {
switch (status) {
case 'success':
return { label: 'Succeeded', className: 'text-emerald-600 dark:text-emerald-400' };
case 'failed':
return { label: 'Failed', className: 'text-red-600 dark:text-red-400' };
case 'in_progress':
return { label: 'In progress', className: 'text-blue-600 dark:text-blue-400' };
case 'rolled_back':
return { label: 'Rolled back', className: 'text-amber-600 dark:text-amber-400' };
case 'cancelled':
return { label: 'Cancelled', className: 'text-muted' };
default:
return { label: status, className: 'text-muted' };
}
};
// A rollback restores the backup taken before this update was applied, so it
// is only offered where that backup is still retained on disk (the backend
// clears backup_path when retention prunes it) and the update actually landed.
const canRollBack = (entry: UpdateHistoryEntry): boolean =>
entry.action === 'update' && entry.status === 'success' && Boolean(entry.backup_path);
export const UpdateHistorySection: Component = () => {
const [history, { refetch }] = createResource(() => UpdatesAPI.listUpdateHistory(HISTORY_LIMIT));
const [confirmEntry, setConfirmEntry] = createSignal<UpdateHistoryEntry | null>(null);
const [isStartingRollback, setIsStartingRollback] = createSignal(false);
const startRollback = async () => {
const entry = confirmEntry();
if (!entry || isStartingRollback()) return;
setIsStartingRollback(true);
try {
const accepted = await updateStore.rollbackUpdate({
eventId: entry.event_id,
fromVersion: updateStore.versionInfo()?.version || entry.version_to,
toVersion: entry.version_from,
});
if (accepted) {
// The global update progress watcher picks the rollback up from the
// status stream and owns the restart/reload flow from here.
setConfirmEntry(null);
} else {
// The store already surfaced the error toast; refresh in case the
// entry state changed underneath us (e.g. backup pruned).
void refetch();
}
} finally {
setIsStartingRollback(false);
}
};
return (
<div class="space-y-4">
<div>
<h4 class="flex items-center gap-2 text-sm font-medium text-base-content">
<HistoryIcon class="w-4 h-4" aria-hidden="true" />
Update History
</h4>
<p class="mt-1 text-xs text-muted">
Updates applied through Pulse, with rollback to the backup taken before each one.
</p>
</div>
<Show
when={!history.loading}
fallback={
<div class="flex items-center gap-2 text-sm text-muted">
<LoadingSpinner size="sm" label="Loading update history" />
Loading update history...
</div>
}
>
<Show
when={!history.error}
fallback={<p class="text-sm text-muted">Update history is unavailable right now.</p>}
>
<Show
when={(history() ?? []).length > 0}
fallback={
<p class="text-sm text-muted">No updates have been applied through Pulse yet.</p>
}
>
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full min-w-[560px] table-fixed text-left text-sm">
<colgroup>
<col class="w-[26%]" />
<col class="w-[20%]" />
<col class="w-[24%]" />
<col class="w-[15%]" />
<col class="w-[15%]" />
</colgroup>
<thead class="border-b border-border bg-surface-alt text-xs uppercase text-muted">
<tr>
<th class="px-3 py-2 font-semibold">When</th>
<th class="px-3 py-2 font-semibold">Action</th>
<th class="px-3 py-2 font-semibold">Version</th>
<th class="px-3 py-2 font-semibold">Result</th>
<th class="px-3 py-2 text-right font-semibold"></th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<For each={history()}>
{(entry) => (
<tr>
<td class="truncate px-3 py-2 text-muted" title={entry.timestamp}>
{formatTimestamp(entry.timestamp)}
</td>
<td class="truncate px-3 py-2 text-base-content">{actionLabel(entry)}</td>
<td
class="truncate px-3 py-2 text-muted"
title={`${formatVersionLabel(entry.version_from)} to ${formatVersionLabel(entry.version_to)}`}
>
{formatVersionLabel(entry.version_from)} &rarr;{' '}
{formatVersionLabel(entry.version_to)}
</td>
<td
class={`truncate px-3 py-2 ${statusPresentation(entry.status).className}`}
title={entry.error?.message || ''}
>
{statusPresentation(entry.status).label}
</td>
<td class="px-3 py-2 text-right">
<Show when={canRollBack(entry)}>
<Button
variant="dangerOutline"
size="xs"
type="button"
onClick={() => setConfirmEntry(entry)}
>
Roll back
</Button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</Show>
</Show>
<Dialog
isOpen={confirmEntry() !== null}
onClose={() => {
if (!isStartingRollback()) setConfirmEntry(null);
}}
panelClass="max-w-lg"
closeOnBackdrop={!isStartingRollback()}
ariaLabel="Confirm rollback"
>
<Show when={confirmEntry()}>
{(entry) => (
<div class="w-full">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold text-base-content">
Roll back to Pulse {formatVersionLabel(entry().version_from)}?
</h2>
</div>
<div class="px-6 py-4 space-y-3 text-sm text-base-content">
<p>
This restores the binary, configuration, and data from the backup taken before
the update to {formatVersionLabel(entry().version_to)} on{' '}
{formatTimestamp(entry().timestamp)}.
</p>
<p>
Settings and alert changes made since that backup will be reverted, and Pulse
will restart to complete the rollback.
</p>
</div>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
<Button
variant="ghost"
size="md"
type="button"
disabled={isStartingRollback()}
onClick={() => setConfirmEntry(null)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
type="button"
disabled={isStartingRollback()}
onClick={() => void startRollback()}
>
{isStartingRollback()
? 'Starting rollback...'
: `Roll back to ${formatVersionLabel(entry().version_from)}`}
</Button>
</div>
</div>
)}
</Show>
</Dialog>
</div>
);
};
@@ -14,6 +14,7 @@ import Package from 'lucide-solid/icons/package';
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
import { buildDockerImageTag, buildLinuxAmd64DownloadCommand } from '@/components/updateVersion';
import { UpdateInstallGuide } from '@/components/Settings/UpdateInstallGuide';
import { UpdateHistorySection } from '@/components/Settings/UpdateHistorySection';
import {
getUpdateChannelCardOptions,
type UpdateChannelOptionValue,
@@ -365,6 +366,10 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
</div>
</div>
</div>
<div class="p-4 sm:p-6">
<UpdateHistorySection />
</div>
</SettingsPanel>
);
};
@@ -0,0 +1,117 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UpdateHistoryEntry } from '@/api/updates';
const mockListUpdateHistory = vi.fn();
const mockStoreRollbackUpdate = vi.fn();
vi.mock('@/api/updates', () => ({
UpdatesAPI: {
listUpdateHistory: (...args: unknown[]) => mockListUpdateHistory(...args),
},
}));
vi.mock('@/stores/updates', () => ({
updateStore: {
versionInfo: () => ({ version: '6.0.5' }),
rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args),
},
}));
import { UpdateHistorySection } from '../UpdateHistorySection';
const baseEntry: UpdateHistoryEntry = {
event_id: '01JZSUCCESS',
timestamp: '2026-07-09T14:39:00Z',
action: 'update',
channel: 'stable',
version_from: '6.0.4',
version_to: '6.0.5',
deployment_type: 'systemd',
initiated_by: 'user',
initiated_via: 'ui',
status: 'success',
duration_ms: 30000,
backup_path: '/var/lib/pulse/backup-20260709-143900',
};
describe('UpdateHistorySection', () => {
beforeEach(() => {
mockListUpdateHistory.mockReset();
mockStoreRollbackUpdate.mockReset();
});
afterEach(() => {
cleanup();
});
it('shows the empty state when no updates were applied', async () => {
mockListUpdateHistory.mockResolvedValue([]);
render(() => <UpdateHistorySection />);
expect(
await screen.findByText('No updates have been applied through Pulse yet.'),
).toBeInTheDocument();
});
it('offers rollback only for successful updates with a retained backup', async () => {
const entries: UpdateHistoryEntry[] = [
baseEntry,
// Backup pruned by retention: backend cleared backup_path.
{
...baseEntry,
event_id: '01JZPRUNED',
version_from: '6.0.3',
version_to: '6.0.4',
backup_path: undefined,
},
// Failed update: nothing to return to.
{
...baseEntry,
event_id: '01JZFAILED',
status: 'failed',
error: { message: 'checksum verification failed' },
},
// A recorded rollback never offers another rollback.
{
...baseEntry,
event_id: '01JZROLLBACK',
action: 'rollback',
version_from: '6.0.5',
version_to: '6.0.4',
},
];
mockListUpdateHistory.mockResolvedValue(entries);
render(() => <UpdateHistorySection />);
await screen.findByText('Failed');
expect(screen.getAllByRole('button', { name: 'Roll back' })).toHaveLength(1);
expect(screen.getByText('Rollback')).toBeInTheDocument();
});
it('confirms which version a rollback restores before starting it', async () => {
mockListUpdateHistory.mockResolvedValue([baseEntry]);
mockStoreRollbackUpdate.mockResolvedValue(true);
render(() => <UpdateHistorySection />);
fireEvent.click(await screen.findByRole('button', { name: 'Roll back' }));
expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' }));
await waitFor(() =>
expect(mockStoreRollbackUpdate).toHaveBeenCalledWith({
eventId: '01JZSUCCESS',
fromVersion: '6.0.5',
toVersion: '6.0.4',
}),
);
await waitFor(() =>
expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(),
);
});
});
@@ -37,6 +37,7 @@ import securityAuthPanelSource from '../SecurityAuthPanel.tsx?raw';
import securityOverviewPanelSource from '../SecurityOverviewPanel.tsx?raw';
import systemLogsPanelSource from '../SystemLogsPanel.tsx?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
import updateHistorySectionSource from '../UpdateHistorySection.tsx?raw';
import updateInstallGuideSource from '../UpdateInstallGuide.tsx?raw';
import agentProfilesPanelSource from '../AgentProfilesPanel.tsx?raw';
import infrastructureWorkspaceSource from '../InfrastructureWorkspace.tsx?raw';
@@ -1083,6 +1084,26 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps update history and rollback on the dedicated section boundary', () => {
// The panel shell mounts the history section; it must not inline history
// rows or rollback confirmation itself.
expect(updatesSettingsPanelSource).toContain(
"import { UpdateHistorySection } from '@/components/Settings/UpdateHistorySection';",
);
expect(updatesSettingsPanelSource).toContain('<UpdateHistorySection />');
expect(updatesSettingsPanelSource).not.toContain('listUpdateHistory');
expect(updatesSettingsPanelSource).not.toContain('rollbackUpdate');
// The section starts rollbacks through the shared store action, never its
// own POST, and always behind an explicit confirmation dialog gated to
// successful updates whose backup is still retained.
expect(updateHistorySectionSource).toContain('updateStore.rollbackUpdate');
expect(updateHistorySectionSource).not.toContain('apiFetchJSON');
expect(updateHistorySectionSource).toContain('ariaLabel="Confirm rollback"');
expect(updateHistorySectionSource).toContain(
"entry.action === 'update' && entry.status === 'success' && Boolean(entry.backup_path)",
);
});
it('keeps infrastructure on a source-manager landing with route-backed dialogs', () => {
expect(infrastructureWorkspaceSource).toContain(
"import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';",
@@ -4,6 +4,7 @@ import { STORAGE_KEYS } from '@/utils/localStorage';
const mockGetVersion = vi.fn();
const mockCheckForUpdates = vi.fn();
const mockApplyUpdate = vi.fn();
const mockRollbackUpdate = vi.fn();
const mockNotifySuccess = vi.fn();
const mockNotifyError = vi.fn();
@@ -12,6 +13,7 @@ vi.mock('@/api/updates', () => ({
getVersion: (...args: unknown[]) => mockGetVersion(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
applyUpdate: (...args: unknown[]) => mockApplyUpdate(...args),
rollbackUpdate: (...args: unknown[]) => mockRollbackUpdate(...args),
},
}));
@@ -55,6 +57,7 @@ describe('updateStore', () => {
mockGetVersion.mockReset();
mockCheckForUpdates.mockReset();
mockApplyUpdate.mockReset();
mockRollbackUpdate.mockReset();
mockNotifySuccess.mockReset();
mockNotifyError.mockReset();
});
@@ -170,6 +173,47 @@ describe('updateStore', () => {
});
});
describe('rollbackUpdate', () => {
const rollbackParams = {
eventId: '01JZEXAMPLE',
fromVersion: 'v1.1.0',
toVersion: 'v1.0.0',
};
it('posts the rollback and records a rollback pending marker before it', async () => {
mockRollbackUpdate.mockResolvedValue({ status: 'started', message: '' });
const updateStore = await loadUpdateStore();
const started = await updateStore.rollbackUpdate(rollbackParams);
expect(started).toBe(true);
expect(mockRollbackUpdate).toHaveBeenCalledWith('01JZEXAMPLE');
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEYS.UPDATES) ?? '{}');
expect(persisted.pendingApply).toMatchObject({
fromVersion: 'v1.1.0',
toVersion: 'v1.0.0',
action: 'rollback',
});
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('clears the marker and toasts the backend error when the rollback is rejected', async () => {
mockRollbackUpdate.mockRejectedValue(
new Error('no retained backup for this update; it may have been pruned by backup retention'),
);
const updateStore = await loadUpdateStore();
const started = await updateStore.rollbackUpdate(rollbackParams);
expect(started).toBe(false);
expect(mockNotifyError).toHaveBeenCalledWith(
'no retained backup for this update; it may have been pruned by backup retention',
);
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEYS.UPDATES) ?? '{}');
expect(persisted.pendingApply).toBeUndefined();
});
});
describe('post-update confirmation', () => {
const seedPendingApply = (fromVersion: string, toVersion: string) => {
localStorage.setItem(
@@ -218,6 +262,32 @@ describe('updateStore', () => {
expect(mockNotifySuccess).toHaveBeenCalledWith('Updated to v1.1.0');
});
it('uses rollback wording when a rollback marker confirms', async () => {
localStorage.setItem(
STORAGE_KEYS.UPDATES,
JSON.stringify({
lastCheck: 0,
pendingApply: {
fromVersion: 'v1.1.0',
toVersion: 'v1.0.0',
startedAt: Date.now(),
action: 'rollback',
},
}),
);
mockGetVersion.mockResolvedValue({ ...baseVersionInfo, version: 'v1.0.0' });
mockCheckForUpdates.mockResolvedValue({
...baseUpdateInfo,
available: false,
currentVersion: 'v1.0.0',
});
const updateStore = await loadUpdateStore();
await updateStore.checkForUpdates(true);
expect(mockNotifySuccess).toHaveBeenCalledWith('Rolled back to v1.0.0');
});
it('clears the marker silently when the version did not change', async () => {
seedPendingApply('v1.0.0', 'v1.1.0');
mockGetVersion.mockResolvedValue({ ...baseVersionInfo, version: 'v1.0.0' });
+44 -5
View File
@@ -17,6 +17,9 @@ interface PendingApply {
fromVersion: string;
toVersion: string;
startedAt: number;
// Distinguishes the post-restart toast wording; absent on markers written
// before rollbacks existed, which read as updates.
action?: 'update' | 'rollback';
}
interface UpdateState {
@@ -79,7 +82,7 @@ const normalizeUpdateInfo = (value: unknown): UpdateInfo | undefined => {
const normalizePendingApply = (value: unknown): PendingApply | undefined => {
if (!isRecord(value)) return undefined;
const { fromVersion, toVersion, startedAt } = value;
const { fromVersion, toVersion, startedAt, action } = value;
if (
typeof fromVersion !== 'string' ||
typeof toVersion !== 'string' ||
@@ -88,7 +91,12 @@ const normalizePendingApply = (value: unknown): PendingApply | undefined => {
return undefined;
}
return { fromVersion, toVersion, startedAt };
return {
fromVersion,
toVersion,
startedAt,
...(action === 'update' || action === 'rollback' ? { action } : {}),
};
};
const normalizeUpdateState = (value: unknown): UpdateState => {
@@ -195,9 +203,13 @@ export const withTransientRetry = async <T>(
}
};
const markApplyStarted = (fromVersion: string, toVersion: string) => {
const markApplyStarted = (
fromVersion: string,
toVersion: string,
action: 'update' | 'rollback' = 'update',
) => {
const state = loadState();
saveState({ ...state, pendingApply: { fromVersion, toVersion, startedAt: Date.now() } });
saveState({ ...state, pendingApply: { fromVersion, toVersion, startedAt: Date.now(), action } });
};
const clearPendingApply = () => {
@@ -224,7 +236,8 @@ const confirmPendingApply = (currentVersion: string, state: UpdateState) => {
saveState(state);
if (currentVersion && currentVersion !== pending.fromVersion) {
notificationStore.success(`Updated to ${formatVersionLabel(currentVersion)}`);
const verb = pending.action === 'rollback' ? 'Rolled back to' : 'Updated to';
notificationStore.success(`${verb} ${formatVersionLabel(currentVersion)}`);
}
};
@@ -248,6 +261,31 @@ const applyUpdate = async (): Promise<boolean> => {
}
};
// Sanctioned rollback of a recorded update: restores the retained backup on
// the selected history entry. Reuses the pending-apply marker so the boot
// after the post-rollback restart confirms the version moved, with rollback
// wording on the toast. Returns true when the backend accepted the request.
const rollbackUpdate = async (params: {
eventId: string;
fromVersion: string;
toVersion: string;
}): Promise<boolean> => {
markApplyStarted(params.fromVersion, params.toVersion, 'rollback');
try {
await UpdatesAPI.rollbackUpdate(params.eventId);
return true;
} catch (error) {
clearPendingApply();
logger.error('Failed to start rollback', error);
notificationStore.error(
error instanceof Error && error.message
? error.message
: 'Unable to start the rollback. Please try again.',
);
return false;
}
};
// Check for updates
const checkForUpdates = async (force = false): Promise<void> => {
// Don't check if already checking
@@ -406,6 +444,7 @@ export const updateStore = {
// Actions
checkForUpdates,
applyUpdate,
rollbackUpdate,
dismissUpdate,
clearDismissed,
+1
View File
@@ -447,6 +447,7 @@ var allRouteAllowlist = []string{
"GET /api/onboarding/deep-link",
"/api/updates/check",
"/api/updates/apply",
"/api/updates/rollback",
"/api/updates/status",
"/api/updates/stream",
"/api/updates/plan",
@@ -125,6 +125,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
// Update routes
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))
r.mux.HandleFunc("/api/updates/rollback", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleRollbackUpdate)))
r.mux.HandleFunc("/api/updates/status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStatus)))
r.mux.HandleFunc("/api/updates/stream", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleUpdateStream)))
r.mux.HandleFunc("/api/updates/plan", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleGetUpdatePlan)))
+94 -5
View File
@@ -36,6 +36,7 @@ type UpdateHandlers struct {
type UpdateManager interface {
CheckForUpdatesWithChannel(ctx context.Context, channel string) (*updates.UpdateInfo, error)
ApplyUpdate(ctx context.Context, req updates.ApplyUpdateRequest) error
RollbackToBackup(ctx context.Context, req updates.RollbackRequest) error
GetStatus() updates.UpdateStatus
GetSSECachedStatus() (updates.UpdateStatus, time.Time)
AddSSEClient(w http.ResponseWriter, clientID string) *updates.SSEClient
@@ -134,6 +135,9 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques
var req struct {
DownloadURL string `json:"downloadUrl"`
// AllowDowngrade opts in to installing a target at or below the
// running version; without it the manager rejects downgrades.
AllowDowngrade bool `json:"allowDowngrade"`
}
if err := decodeStrictJSONBody(r.Body, &req); err != nil {
@@ -184,10 +188,11 @@ func (h *UpdateHandlers) HandleApplyUpdate(w http.ResponseWriter, r *http.Reques
}
applyReq := updates.ApplyUpdateRequest{
DownloadURL: req.DownloadURL,
Channel: channel,
InitiatedBy: updates.InitiatedByUser,
InitiatedVia: updates.InitiatedViaUI,
DownloadURL: req.DownloadURL,
Channel: channel,
InitiatedBy: updates.InitiatedByUser,
InitiatedVia: updates.InitiatedViaUI,
AllowDowngrade: req.AllowDowngrade,
}
result := make(chan error, 1)
@@ -255,7 +260,8 @@ func classifyApplyUpdateStartError(err error) (int, string) {
return http.StatusConflict, "Update already in progress"
case strings.Contains(errMsg, "download url is required"), strings.Contains(errMsg, "invalid download url"):
return http.StatusBadRequest, "Invalid download URL"
case strings.Contains(errMsg, "stable channel cannot install prerelease builds"):
case strings.Contains(errMsg, "stable channel cannot install prerelease builds"),
strings.Contains(errMsg, "not newer than the running version"):
return http.StatusConflict, err.Error()
case strings.Contains(errMsg, "cannot be applied in docker environment"),
strings.Contains(errMsg, "manual migration required"),
@@ -266,6 +272,89 @@ func classifyApplyUpdateStartError(err error) (int, string) {
}
}
// HandleRollbackUpdate handles rollback requests: it restores the retained
// backup recorded on the selected update history entry.
func (h *UpdateHandlers) HandleRollbackUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
defer r.Body.Close()
var req struct {
EventID string `json:"eventId"`
}
if err := decodeStrictJSONBody(r.Body, &req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.EventID = strings.TrimSpace(req.EventID)
if req.EventID == "" {
http.Error(w, "Event ID is required", http.StatusBadRequest)
return
}
rollbackReq := updates.RollbackRequest{
EventID: req.EventID,
InitiatedBy: updates.InitiatedByUser,
InitiatedVia: updates.InitiatedViaUI,
}
result := make(chan error, 1)
// Start rollback in background with a new context (not request context which gets canceled)
go func() {
result <- h.manager.RollbackToBackup(context.Background(), rollbackReq)
}()
select {
case err := <-result:
if err != nil {
statusCode, msg := classifyRollbackStartError(err)
if statusCode >= http.StatusInternalServerError {
log.Error().Err(err).Str("event_id", req.EventID).Msg("Failed to start rollback")
} else {
log.Warn().Err(err).Str("event_id", req.EventID).Msg("Rollback request rejected")
}
http.Error(w, msg, statusCode)
return
}
case <-time.After(applyUpdateStartAckTimeout):
}
// Return success immediately
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{
"status": "started",
"message": "Rollback process started",
}); err != nil {
log.Error().Err(err).Msg("Failed to encode rollback start response")
}
}
func classifyRollbackStartError(err error) (int, string) {
errMsg := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(errMsg, "already in progress"):
return http.StatusConflict, "Update already in progress"
case strings.Contains(errMsg, "event id is required"):
return http.StatusBadRequest, "Event ID is required"
case strings.Contains(errMsg, "history entry not found"):
return http.StatusNotFound, "Update history entry not found"
case strings.Contains(errMsg, "no retained backup"),
strings.Contains(errMsg, "backup no longer exists"),
strings.Contains(errMsg, "not a managed update backup"),
strings.Contains(errMsg, "cannot be applied in docker environment"):
return http.StatusConflict, err.Error()
default:
return http.StatusInternalServerError, "Failed to start rollback"
}
}
// HandleUpdateStatus handles update status requests with rate limiting
func (h *UpdateHandlers) HandleUpdateStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
+148
View File
@@ -19,6 +19,7 @@ import (
type MockUpdateManager struct {
CheckForUpdatesFunc func(ctx context.Context, channel string) (*updates.UpdateInfo, error)
ApplyUpdateFunc func(ctx context.Context, req updates.ApplyUpdateRequest) error
RollbackToBackupFunc func(ctx context.Context, req updates.RollbackRequest) error
GetStatusFunc func() updates.UpdateStatus
GetSSECachedStatusFunc func() (updates.UpdateStatus, time.Time)
AddSSEClientFunc func(w http.ResponseWriter, clientID string) *updates.SSEClient
@@ -39,6 +40,13 @@ func (m *MockUpdateManager) ApplyUpdate(ctx context.Context, req updates.ApplyUp
return nil
}
func (m *MockUpdateManager) RollbackToBackup(ctx context.Context, req updates.RollbackRequest) error {
if m.RollbackToBackupFunc != nil {
return m.RollbackToBackupFunc(ctx, req)
}
return nil
}
func (m *MockUpdateManager) GetStatus() updates.UpdateStatus {
if m.GetStatusFunc != nil {
return m.GetStatusFunc()
@@ -155,6 +163,146 @@ func TestHandleApplyUpdate_Success(t *testing.T) {
// Note: ApplyUpdate runs in background, so we just check it was accepted
}
func TestHandleApplyUpdate_PassesAllowDowngrade(t *testing.T) {
received := make(chan updates.ApplyUpdateRequest, 1)
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
received <- req
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl": "https://example.com/update.tar.gz", "allowDowngrade": true}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
select {
case req := <-received:
if !req.AllowDowngrade {
t.Fatal("expected AllowDowngrade to be passed through to the manager request")
}
case <-time.After(time.Second):
t.Fatal("ApplyUpdate was not invoked")
}
}
func TestHandleRollbackUpdate_Success(t *testing.T) {
received := make(chan updates.RollbackRequest, 1)
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
received <- req
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"eventId": "01JZEXAMPLE"}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["status"] != "started" {
t.Fatalf("expected started status, got %q", resp["status"])
}
select {
case req := <-received:
if req.EventID != "01JZEXAMPLE" {
t.Fatalf("expected event ID to be passed through, got %q", req.EventID)
}
if req.InitiatedBy != updates.InitiatedByUser || req.InitiatedVia != updates.InitiatedViaUI {
t.Fatalf("expected user/ui initiation, got %q/%q", req.InitiatedBy, req.InitiatedVia)
}
case <-time.After(time.Second):
t.Fatal("RollbackToBackup was not invoked")
}
}
func TestHandleRollbackUpdate_InvalidRequests(t *testing.T) {
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
t.Fatal("RollbackToBackup should not be called for invalid requests")
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
t.Run("rejects non-POST", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/rollback", nil)
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"eventId":"01JZEXAMPLE","unexpected":true}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("rejects missing event ID", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"eventId":" "}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
}
func TestHandleRollbackUpdate_ErrorMapping(t *testing.T) {
cases := []struct {
name string
managerErr error
wantCode int
}{
{"entry not found", errors.New("update history entry not found: x"), http.StatusNotFound},
{"backup pruned", errors.New("no retained backup for this update; it may have been pruned by backup retention"), http.StatusConflict},
{"backup missing on disk", errors.New("backup no longer exists on disk: /var/lib/pulse/backup-1"), http.StatusConflict},
{"unmanaged path", errors.New("backup path is not a managed update backup: /etc/passwd"), http.StatusConflict},
{"docker", errors.New("rollback cannot be applied in Docker environment"), http.StatusConflict},
{"in progress", errors.New("update already in progress"), http.StatusConflict},
{"unexpected", errors.New("disk exploded"), http.StatusInternalServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
return tc.managerErr
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(`{"eventId":"01JZEXAMPLE"}`))
h.HandleRollbackUpdate(w, r)
if w.Code != tc.wantCode {
t.Fatalf("expected status %d, got %d: %s", tc.wantCode, w.Code, w.Body.String())
}
})
}
}
func TestHandleApplyUpdate_AlreadyInProgress(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
+195 -1
View File
@@ -201,6 +201,19 @@ type ApplyUpdateRequest struct {
InitiatedBy InitiatedBy
InitiatedVia InitiatedVia
Notes string
// AllowDowngrade permits installing a target at or below the running
// version. The normal apply path rejects those so a valid-but-older
// release asset URL cannot silently downgrade; sanctioned rollbacks go
// through RollbackToBackup instead.
AllowDowngrade bool
}
// RollbackRequest describes a rollback of a recorded update, restoring the
// retained backup captured before that update was applied.
type RollbackRequest struct {
EventID string
InitiatedBy InitiatedBy
InitiatedVia InitiatedVia
}
// NewManager creates a new update manager
@@ -604,10 +617,23 @@ func (m *Manager) ApplyUpdate(ctx context.Context, req ApplyUpdateRequest) error
} else {
targetVersion, validationErr := ValidateApplyTargetVersion(channel, req.DownloadURL)
if validationErr != nil {
m.updateStatus("error", 10, "Update rejected", validationErr)
return validationErr
}
artifact = resolvedUpdateArtifact{downloadURL: req.DownloadURL, version: targetVersion}
}
// A valid release asset URL can still point at an older release than the
// running binary; without this guard the "update" would silently install a
// downgrade. Sanctioned downgrades either restore a retained backup via
// RollbackToBackup or set AllowDowngrade explicitly on the request.
if !req.AllowDowngrade {
if err := ensureApplyTargetIsNewer(currentInfo.Version, artifact.version); err != nil {
m.updateStatus("error", 10, "Update rejected", err)
return err
}
}
initiatedBy := req.InitiatedBy
if initiatedBy == "" {
initiatedBy = InitiatedByUser
@@ -1663,9 +1689,177 @@ func (m *Manager) createBackup(ctx context.Context) (string, error) {
return backupDir, nil
}
// ensureApplyTargetIsNewer rejects update targets at or below the running
// version so a valid-but-older release asset URL cannot silently downgrade.
// Versions that do not parse as semver (development builds) are left to the
// existing URL and channel validation.
func ensureApplyTargetIsNewer(currentVersion, targetVersion string) error {
current, err := ParseVersion(currentVersion)
if err != nil {
return nil
}
target, err := ParseVersion(targetVersion)
if err != nil {
return nil
}
if target.Compare(current) <= 0 {
return fmt.Errorf("target version %s is not newer than the running version %s; use the update history rollback to return to an earlier version", target.String(), current.String())
}
return nil
}
// validateRetainedBackupDir confirms a history-recorded backup path still
// points at an existing managed update backup directory before anything is
// restored from it. The history file is server-owned state, but the path is
// re-checked against the managed backup roots so a corrupted or hand-edited
// history entry cannot direct a restore from an arbitrary filesystem location.
func validateRetainedBackupDir(raw string) (string, error) {
cleaned := filepath.Clean(strings.TrimSpace(raw))
managed := false
for _, root := range managedUpdateBackupRoots() {
if filepath.Dir(cleaned) != root {
continue
}
if strings.HasPrefix(filepath.Base(cleaned), managedUpdateBackupPrefix(root)) {
managed = true
break
}
}
if !managed {
return "", fmt.Errorf("backup path is not a managed update backup: %s", cleaned)
}
info, err := os.Stat(cleaned)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("backup no longer exists on disk: %s", cleaned)
}
return "", fmt.Errorf("stat backup directory %q: %w", cleaned, err)
}
if !info.IsDir() {
return "", fmt.Errorf("backup path is not a directory: %s", cleaned)
}
return cleaned, nil
}
// RollbackToBackup restores the retained backup recorded on an update history
// entry, records the rollback as its own history entry, and restarts through
// the same exit-for-systemd path as ApplyUpdate. It is a purely local
// restore: no release download, broker call, or edition gate is involved, so
// it behaves identically on community and Pro binaries.
func (m *Manager) RollbackToBackup(ctx context.Context, req RollbackRequest) error {
if m.history == nil {
return fmt.Errorf("update history is not available")
}
eventID := strings.TrimSpace(req.EventID)
if eventID == "" {
return fmt.Errorf("event ID is required")
}
source, err := m.history.GetEntry(eventID)
if err != nil {
return fmt.Errorf("update history entry not found: %s", eventID)
}
if strings.TrimSpace(source.BackupPath) == "" {
return fmt.Errorf("no retained backup for this update; it may have been pruned by backup retention")
}
backupDir, err := validateRetainedBackupDir(source.BackupPath)
if err != nil {
return err
}
currentInfo, _ := GetCurrentVersion()
if currentInfo.IsDocker {
return fmt.Errorf("rollback cannot be applied in Docker environment")
}
// Rollback and update share the single in-flight slot: restoring a backup
// while an update is replacing the same files would corrupt both.
m.updateMu.Lock()
if m.updateInFlight {
m.updateMu.Unlock()
return fmt.Errorf("update already in progress")
}
m.updateInFlight = true
m.updateMu.Unlock()
defer func() {
m.updateMu.Lock()
m.updateInFlight = false
m.updateMu.Unlock()
}()
initiatedBy := req.InitiatedBy
if initiatedBy == "" {
initiatedBy = InitiatedByUser
}
initiatedVia := req.InitiatedVia
if initiatedVia == "" {
initiatedVia = InitiatedViaAPI
}
m.updateStatus("restoring", 20, fmt.Sprintf("Restoring Pulse %s from backup...", source.VersionFrom))
start := time.Now()
rollbackEventID := m.createHistoryEntry(ctx, UpdateHistoryEntry{
Action: "rollback",
Channel: source.Channel,
VersionFrom: currentInfo.Version,
VersionTo: source.VersionFrom,
DeploymentType: currentInfo.DeploymentType,
InitiatedBy: initiatedBy,
InitiatedVia: initiatedVia,
Status: StatusInProgress,
BackupPath: backupDir,
RelatedEventID: source.EventID,
})
var runErr error
defer func() {
if rollbackEventID == "" {
return
}
status := StatusSuccess
if runErr != nil {
status = StatusFailed
}
m.completeHistoryEntry(ctx, rollbackEventID, status, start, runErr)
}()
if err := m.restoreBackup(backupDir); err != nil {
restoreErr := fmt.Errorf("failed to restore backup: %w", err)
m.updateStatus("error", 40, "Failed to restore backup", restoreErr)
runErr = restoreErr
return restoreErr
}
// The update this backup predates is no longer the running install.
m.updateHistoryEntry(ctx, eventID, func(entry *UpdateHistoryEntry) {
entry.Status = StatusRolledBack
})
m.updateStatus("restarting", 95, "Restarting service...")
// Schedule a clean exit after a short delay - systemd will restart us
if !dockerUpdatesAllowed() {
go func() {
time.Sleep(2 * time.Second)
log.Info().Msg("Exiting for restart after rollback")
os.Exit(0)
}()
} else {
log.Info().Msg("Skipping process exit after rollback (mock/CI mode)")
}
m.updateStatus("completed", 100, "Rollback completed, restarting...")
return nil
}
// restoreBackup restores from a backup
func (m *Manager) restoreBackup(backupDir string) error {
pulseDir := "/opt/pulse"
pulseDir := os.Getenv("PULSE_INSTALL_DIR")
if pulseDir == "" {
pulseDir = "/opt/pulse"
}
// Restore directories
dirsToRestore := []string{"data", "config"}
+341
View File
@@ -0,0 +1,341 @@
package updates
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureApplyTargetIsNewer(t *testing.T) {
cases := []struct {
name string
current string
target string
wantErr bool
}{
{"newer patch allowed", "6.0.4", "6.0.5", false},
{"older patch blocked", "6.0.5", "6.0.4", true},
{"same version blocked", "6.0.5", "6.0.5", true},
{"rc to stable of same base allowed", "6.0.5-rc.4", "6.0.5", false},
{"stable to rc of same base blocked", "6.0.5", "6.0.5-rc.4", true},
{"older rc blocked", "6.0.5-rc.4", "6.0.5-rc.3", true},
{"newer rc allowed", "6.0.5-rc.4", "6.0.5-rc.5", false},
{"v prefix handled", "v6.0.5", "v6.0.6", false},
{"unparseable current allowed", "not-a-version", "6.0.4", false},
{"unparseable target allowed", "6.0.5", "not-a-version", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ensureApplyTargetIsNewer(tc.current, tc.target)
if tc.wantErr && err == nil {
t.Fatalf("expected downgrade error for current=%s target=%s", tc.current, tc.target)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for current=%s target=%s: %v", tc.current, tc.target, err)
}
if err != nil && !strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected canonical downgrade message, got %q", err.Error())
}
})
}
}
func TestApplyUpdateRejectsDowngradeBeforeDownload(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
oldBuildVersion := BuildVersion
BuildVersion = "6.0.5"
t.Cleanup(func() { BuildVersion = oldBuildVersion })
manager := &Manager{}
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
DownloadURL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
})
if err == nil {
t.Fatal("expected downgrade to be rejected")
}
if !strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected downgrade rejection, got %v", err)
}
status := manager.GetStatus()
if status.Status != "error" {
t.Fatalf("expected error status after rejected downgrade, got %q", status.Status)
}
}
func TestApplyUpdateAllowDowngradeSkipsGuard(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
// Serve 404s locally so the apply fails fast at the download stage
// without touching the real release host.
server := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(server.Close)
t.Setenv("PULSE_UPDATE_SERVER", server.URL)
oldBuildVersion := BuildVersion
BuildVersion = "6.0.5"
t.Cleanup(func() { BuildVersion = oldBuildVersion })
manager := &Manager{}
// With the opt-in set the request must get past the downgrade guard; it
// then fails later at the download step instead.
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
DownloadURL: server.URL + "/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
AllowDowngrade: true,
})
if err == nil {
t.Fatal("expected apply to fail at a later stage in this environment")
}
if strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected the downgrade guard to be skipped, got %v", err)
}
}
func TestValidateRetainedBackupDir(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
valid := filepath.Join(dataDir, "backup-20260710-010101")
if err := os.MkdirAll(valid, 0755); err != nil {
t.Fatalf("mkdir valid backup: %v", err)
}
t.Run("accepts managed backup", func(t *testing.T) {
got, err := validateRetainedBackupDir(valid)
if err != nil {
t.Fatalf("expected managed backup to validate: %v", err)
}
if got != filepath.Clean(valid) {
t.Fatalf("expected %q, got %q", valid, got)
}
})
t.Run("rejects path outside managed roots", func(t *testing.T) {
outside := filepath.Join(t.TempDir(), "backup-20260710-010101")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatalf("mkdir outside backup: %v", err)
}
if _, err := validateRetainedBackupDir(outside); err == nil {
t.Fatal("expected unmanaged path to be rejected")
}
})
t.Run("rejects wrong prefix inside managed root", func(t *testing.T) {
wrongPrefix := filepath.Join(dataDir, "snapshots-20260710-010101")
if err := os.MkdirAll(wrongPrefix, 0755); err != nil {
t.Fatalf("mkdir wrong prefix: %v", err)
}
if _, err := validateRetainedBackupDir(wrongPrefix); err == nil {
t.Fatal("expected wrong-prefix path to be rejected")
}
})
t.Run("rejects missing directory", func(t *testing.T) {
missing := filepath.Join(dataDir, "backup-19990101-000000")
_, err := validateRetainedBackupDir(missing)
if err == nil || !strings.Contains(err.Error(), "no longer exists") {
t.Fatalf("expected missing-backup error, got %v", err)
}
})
}
func TestRollbackToBackupRestoresFilesAndRecordsHistory(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
dataDir := t.TempDir()
installDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
t.Setenv("PULSE_INSTALL_DIR", installDir)
// Backup contents captured before the recorded update. The backup holds
// no "pulse" binary on purpose: restoring one would overwrite the running
// test executable via os.Executable.
backupDir := filepath.Join(dataDir, "backup-20260710-020202")
if err := os.MkdirAll(filepath.Join(backupDir, "config"), 0755); err != nil {
t.Fatalf("mkdir backup config: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, "config", "system.json"), []byte(`{"restored":true}`), 0600); err != nil {
t.Fatalf("write backup config: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, ".env"), []byte("RESTORED=1\n"), 0600); err != nil {
t.Fatalf("write backup env: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, "VERSION"), []byte("6.0.4\n"), 0644); err != nil {
t.Fatalf("write backup VERSION: %v", err)
}
// Current install state that the rollback must replace.
if err := os.MkdirAll(filepath.Join(installDir, "config"), 0755); err != nil {
t.Fatalf("mkdir install config: %v", err)
}
if err := os.WriteFile(filepath.Join(installDir, "config", "system.json"), []byte(`{"restored":false}`), 0600); err != nil {
t.Fatalf("write install config: %v", err)
}
history, err := NewUpdateHistory(t.TempDir())
if err != nil {
t.Fatalf("NewUpdateHistory: %v", err)
}
sourceEventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
VersionFrom: "6.0.4",
VersionTo: "6.0.5",
BackupPath: backupDir,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
manager := &Manager{history: history}
if err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: sourceEventID}); err != nil {
t.Fatalf("RollbackToBackup: %v", err)
}
restoredConfig, err := os.ReadFile(filepath.Join(installDir, "config", "system.json"))
if err != nil {
t.Fatalf("read restored config: %v", err)
}
if !strings.Contains(string(restoredConfig), `"restored":true`) {
t.Fatalf("expected config to be restored from backup, got %s", restoredConfig)
}
restoredEnv, err := os.ReadFile(filepath.Join(installDir, ".env"))
if err != nil {
t.Fatalf("read restored .env: %v", err)
}
if !strings.Contains(string(restoredEnv), "RESTORED=1") {
t.Fatalf("expected .env to be restored, got %s", restoredEnv)
}
source, err := history.GetEntry(sourceEventID)
if err != nil {
t.Fatalf("GetEntry source: %v", err)
}
if source.Status != StatusRolledBack {
t.Fatalf("expected source entry to be marked rolled_back, got %q", source.Status)
}
entries := history.ListEntries(HistoryFilter{Action: "rollback"})
if len(entries) != 1 {
t.Fatalf("expected one rollback history entry, got %d", len(entries))
}
rollback := entries[0]
if rollback.Status != StatusSuccess {
t.Fatalf("expected rollback entry success, got %q", rollback.Status)
}
if rollback.VersionTo != "6.0.4" {
t.Fatalf("expected rollback target version 6.0.4, got %q", rollback.VersionTo)
}
if rollback.RelatedEventID != sourceEventID {
t.Fatalf("expected rollback to reference the source update entry")
}
status := manager.GetStatus()
if status.Status != "completed" {
t.Fatalf("expected completed status after rollback, got %q", status.Status)
}
}
func TestRollbackToBackupRejectsBadRequests(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
history, err := NewUpdateHistory(t.TempDir())
if err != nil {
t.Fatalf("NewUpdateHistory: %v", err)
}
manager := &Manager{history: history}
t.Run("no history sink", func(t *testing.T) {
bare := &Manager{}
if err := bare.RollbackToBackup(context.Background(), RollbackRequest{EventID: "x"}); err == nil {
t.Fatal("expected error without history")
}
})
t.Run("empty event ID", func(t *testing.T) {
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: " "})
if err == nil || !strings.Contains(err.Error(), "event ID is required") {
t.Fatalf("expected event ID error, got %v", err)
}
})
t.Run("unknown entry", func(t *testing.T) {
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: "does-not-exist"})
if err == nil || !strings.Contains(err.Error(), "history entry not found") {
t.Fatalf("expected not-found error, got %v", err)
}
})
t.Run("entry without backup", func(t *testing.T) {
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no retained backup") {
t.Fatalf("expected no-backup error, got %v", rollbackErr)
}
})
t.Run("pruned backup directory", func(t *testing.T) {
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
BackupPath: filepath.Join(dataDir, "backup-20200101-000000"),
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no longer exists") {
t.Fatalf("expected missing-backup error, got %v", rollbackErr)
}
})
t.Run("concurrent update in flight", func(t *testing.T) {
backupDir := filepath.Join(dataDir, "backup-20260710-030303")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatalf("mkdir backup: %v", err)
}
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
BackupPath: backupDir,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
manager.updateMu.Lock()
manager.updateInFlight = true
manager.updateMu.Unlock()
t.Cleanup(func() {
manager.updateMu.Lock()
manager.updateInFlight = false
manager.updateMu.Unlock()
})
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "already in progress") {
t.Fatalf("expected in-progress error, got %v", rollbackErr)
}
})
}