diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index 32d5d97431..811bbb6cec 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -15,6 +15,50 @@
## Purpose
+### TrueNAS SCALE removal retires every owned middleware boot task before recovery erasure
+
+TrueNAS SCALE has an independent activation owner: its Init/Shutdown bootstrap
+can recreate and enable the systemd unit after a normal disable. A recovered
+state directory or an unrelated `/etc/version` alone is not appliance identity.
+The uninstall caller selects SCALE only from a bootstrap footprint or vendor
+marker. The existing protected/explicit state-removal authority must select the
+same state directory and exact bootstrap path; linked, writable or non-regular
+bootstrap files refuse before task/service mutation.
+
+One complete, bounded middleware inventory must identify every exact generated
+SCRIPT/POSTINIT task, including duplicates. Unknown ownership, indirect command
+activation or another-install bootstrap refuses without deleting unrelated
+tasks. The JSON reader rejects duplicate keys/IDs, invalid types, failed/empty/
+diagnostic/incomplete reads, over 512 tasks or over 1 MiB; it returns only owned
+numeric IDs and enablement, never task commands, names or credentials. A
+missing/empty SCRIPT path or COMMAND target is unknown activation, not an
+unrelated task or evidence that the owned task is absent. All owned
+tasks are disabled with checked calls and independent exact-set readback before
+systemd stop. A partial stop/disable stays stopped/disabled: do not re-enable or
+automatically restart after a refusal.
+
+The complete systemd target set and independent kernel collector/bootstrap
+process inventory and a final exact disabled-task readback must be quiescent
+before same-instance server removal, including task changes during systemd stop.
+After remote confirmation, the exact disabled task set is re-read before the first
+task deletion. Every owned deletion is checked; final whole-unit/process
+quiescence and complete task absence precede credential, stored binary,
+bootstrap and state erasure. A changed set, refusal, ignored deletion, new task
+or reactivation retains local recovery, even if earlier task/server operations
+succeeded. No favourable replacement observation, task recreation or credential
+churn is performed. Unrelated tasks survive unchanged.
+
+`TestInstallSHTrueNASScaleBootTasksRetireBeforeRecoveryErasure` requires all
+sixteen `agent_truenas_teardown.py` actual-function/full-caller controls,
+including multiple tasks, partial/refused/unknown operations, post-remote
+changes, ignored/recreated deletion, process privacy and a real timeout child.
+These run against closed middleware/systemd/process models and private ordinary-
+user files, not a native appliance. Existing systemd/OpenRC/DSM/rc.d/SysV gates,
+transport trust and identity remain. TrueNAS CORE retains its linked rc.d/
+appliance refusal; Unraid/QNAP boot ownership, Windows and signed installed
+Linux/PowerShell/native/TLS/cleanup acceptance remain separate. This does not
+expand the original removal job into macOS or introduce another dispatcher.
+
### DSM Upstart removal retains recovery until automatic activation and the job are absent
DSM 6 Upstart jobs (including leftovers on DSM 7) are not classified as SysV.
diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md
index e71c8a5d55..1f61cabe18 100644
--- a/docs/release-control/v6/internal/subsystems/deployment-installability.md
+++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md
@@ -16,6 +16,50 @@
## Purpose
+### TrueNAS SCALE removal retires every owned middleware boot task before recovery erasure
+
+TrueNAS SCALE has an independent activation owner: its Init/Shutdown bootstrap
+can recreate and enable the systemd unit after a normal disable. A recovered
+state directory or an unrelated `/etc/version` alone is not appliance identity.
+The uninstall caller selects SCALE only from a bootstrap footprint or vendor
+marker. The existing protected/explicit state-removal authority must select the
+same state directory and exact bootstrap path; linked, writable or non-regular
+bootstrap files refuse before task/service mutation.
+
+One complete, bounded middleware inventory must identify every exact generated
+SCRIPT/POSTINIT task, including duplicates. Unknown ownership, indirect command
+activation or another-install bootstrap refuses without deleting unrelated
+tasks. The JSON reader rejects duplicate keys/IDs, invalid types, failed/empty/
+diagnostic/incomplete reads, over 512 tasks or over 1 MiB; it returns only owned
+numeric IDs and enablement, never task commands, names or credentials. A
+missing/empty SCRIPT path or COMMAND target is unknown activation, not an
+unrelated task or evidence that the owned task is absent. All owned
+tasks are disabled with checked calls and independent exact-set readback before
+systemd stop. A partial stop/disable stays stopped/disabled: do not re-enable or
+automatically restart after a refusal.
+
+The complete systemd target set and independent kernel collector/bootstrap
+process inventory and a final exact disabled-task readback must be quiescent
+before same-instance server removal, including task changes during systemd stop.
+After remote confirmation, the exact disabled task set is re-read before the first
+task deletion. Every owned deletion is checked; final whole-unit/process
+quiescence and complete task absence precede credential, stored binary,
+bootstrap and state erasure. A changed set, refusal, ignored deletion, new task
+or reactivation retains local recovery, even if earlier task/server operations
+succeeded. No favourable replacement observation, task recreation or credential
+churn is performed. Unrelated tasks survive unchanged.
+
+`TestInstallSHTrueNASScaleBootTasksRetireBeforeRecoveryErasure` requires all
+sixteen `agent_truenas_teardown.py` actual-function/full-caller controls,
+including multiple tasks, partial/refused/unknown operations, post-remote
+changes, ignored/recreated deletion, process privacy and a real timeout child.
+These run against closed middleware/systemd/process models and private ordinary-
+user files, not a native appliance. Existing systemd/OpenRC/DSM/rc.d/SysV gates,
+transport trust and identity remain. TrueNAS CORE retains its linked rc.d/
+appliance refusal; Unraid/QNAP boot ownership, Windows and signed installed
+Linux/PowerShell/native/TLS/cleanup acceptance remain separate. This does not
+expand the original removal job into macOS or introduce another dispatcher.
+
### DSM Upstart removal retains recovery until automatic activation and the job are absent
DSM 6 Upstart jobs (including leftovers on DSM 7) are not classified as SysV.
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 342b678f57..f2326298ba 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -9416,3 +9416,21 @@ provider precedence, stopping/recovery and zero. The local
Workloads snapshot adapter/drawer, History and CSS on desktop and phone, with
synthetic GET responses. These controls do not establish native restart, thaw,
covered-filesystem writes, installed recovery or containing software.
+
+### Guest backup completion readout (12 October 2026)
+
+The existing guest Overview's Last completed backup row shows elapsed age
+(hours/minutes for recent points, days for older ones), never a rolling-day
+Today/Yesterday claim. A validated completion also has visible UTC date/time
+in a semantic `time` element with its original ISO timestamp. Invalid, future
+or absent times produce no completion element. The row still distinguishes
+running activity from completed protection and preserves the policy's tone;
+the passive clock may rejudge a previously future timestamp when it becomes
+valid, but cannot supply new completion evidence.
+
+`GuestDrawer.backup-clock.test.tsx` and the connected drawer/model controls
+cover these states and update on time alone. Desktop Chromium and touch-phone
+WebKit exercise the real policy producer, consuming drawer, shared clock and
+existing table evidence. No route, guest check, backup, threshold, collector or
+permission changes. This presentation result does not establish thaw, covered
+filesystem writes, native restart recovery or containing released software.
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 15bae16d7e..3904c300de 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -4082,3 +4082,21 @@ notification is introduced. Current snapshot/delta fast paths retain their
existing scope; only availability complete-record replacement changes, never
other partial provider facets or independent metric updates. This is truthful
presentation and scoped invalidation, not a measured whole-fleet CPU saving.
+
+### Open guest drawers age completed backups passively (12 October 2026)
+
+`useGuestDrawerState` derives completed-backup age and policy tone from the
+existing shared 30-second clock, not just a new guest snapshot. This adds a
+clock subscription for the mounted drawer, not a timer per row, network read,
+refresh, guest probe or alert evaluation. Disposal releases the subscription.
+The supplied completed timestamp is immutable under ticks and backup activity;
+only replacement completion evidence changes it. Configured fresh/stale hours
+retain their existing inclusive boundaries and unknown/absent distinctions.
+
+`GuestDrawer.backup-clock.test.tsx` covers passive default/custom policy
+crossings, unchanged completion time, tab round trips, later completion,
+future/backwards-clock uncertainty and timer disposal. Connected
+`GuestDrawer.test.tsx` and `guestDrawerModel.branchcov2.test.ts` retain existing
+backup-activity, configured policy and caution/absence controls. Browser proof
+uses the real read-only AlertsAPI/store and existing drawer/table badge with
+synthetic timestamps, not native backup safety, guest recovery or availability.
diff --git a/frontend-modern/browser-verification/2ebeb891380553fa.json b/frontend-modern/browser-verification/2ebeb891380553fa.json
new file mode 100644
index 0000000000..c70030850d
--- /dev/null
+++ b/frontend-modern/browser-verification/2ebeb891380553fa.json
@@ -0,0 +1,79 @@
+{
+ "version": 1,
+ "result": "passed",
+ "base_sha": "0d8ade2b3b8cf73a60eb9efe07534e45f1d7072a",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts",
+ "frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "801aa50aec4c0ec7b801f4f14da60ef9c9a56a886f6df3f509f3fc844ecf4dca",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "66ada43fb44fcdbdbbde5640f21ed92d84115f27011a4d49bdcc8b711e0dc088",
+ "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f3a446f40d39212d00dcbef049918297f211fdf5b8ee3b96094bf6438cbe17a9"
+ },
+ "routes": [
+ "/tmp/backup-clock/index.html (isolated existing production guest drawer/table fixture, no product route added)"
+ ],
+ "states": [
+ "unchanged VM/LXC completion across a saved one-hour freshness boundary",
+ "default 24-hour policy covered by connected source controls",
+ "running/ended activity separate from prior completion",
+ "new completion then passive minute ageing",
+ "absent/invalid/future timestamps",
+ "previous-calendar-day completion"
+ ],
+ "interactions": [
+ "read synthetic saved policy through actual AlertsAPI/store",
+ "advance passive browser clock without API requests; both drawer and table reclassify",
+ "Manage/Overview round trip retains original completed time",
+ "replace completion evidence without remounting the workload",
+ "keyboard and touch open existing table evidence; Escape closes and restores focus",
+ "inspect six final PNG captures with native view_image at original detail"
+ ],
+ "viewports": [
+ {
+ "width": 1440,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "verified_at": "2026-10-12T16:49:58.639554Z",
+ "proof_scope": "Read-only synthetic alert policy through real AlertsAPI/store, guest drawer state/model/Overview, existing backup table badge and production CSS. Passive Playwright clocks, no native backup/recovery or availability claim.",
+ "browser_proof": {
+ "checks": 32,
+ "loaded_production_hashes": 115,
+ "captures": 6,
+ "client_exit": 0,
+ "playwright": "1.56.1",
+ "browsers": [
+ {
+ "name": "desktop-light",
+ "version": "141.0.7390.37",
+ "viewport": {
+ "width": 1440,
+ "height": 900
+ }
+ },
+ {
+ "name": "phone-dark",
+ "version": "26.0",
+ "viewport": {
+ "width": 390,
+ "height": 844
+ }
+ }
+ ],
+ "cleanup": {
+ "desktop-light-context": "closed",
+ "desktop-light-browser": "closed",
+ "phone-dark-context": "closed",
+ "phone-dark-browser": "closed",
+ "server": "closed"
+ },
+ "dossier": "RECEIPTS.md / browser/result.json and native-image-review.json"
+ }
+}
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
index a1f14123aa..85201a76e3 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
@@ -319,12 +319,12 @@ describe('GuestDrawer', () => {
const details = technicalDetails();
expect(details.getByText('Backup time unavailable: invalid timestamp.')).toBeVisible();
expect(details.queryByText('No completed backup found')).not.toBeInTheDocument();
- expect(details.queryByText('Today')).not.toBeInTheDocument();
+ expect(details.queryByText('1 hour ago')).not.toBeInTheDocument();
setGuest({ ...guest(), lastBackup: Date.now() + 86_400_000, backupInProgress: true });
expect(details.getByText(/timestamp is in the future/)).toBeVisible();
expect(details.getByText('Running · not completed yet')).toBeVisible();
setGuest({ ...guest(), lastBackup: Date.now() - 3600_000, backupInProgress: false });
- expect(details.getByText('Today')).toBeVisible();
+ expect(details.getByText('1 hour ago')).toBeVisible();
expect(details.queryByText(/unavailable/)).not.toBeInTheDocument();
setGuest({ ...guest(), lastBackup: 0 });
expect(details.getByText('No completed backup found')).toBeVisible();
@@ -1011,17 +1011,17 @@ describe('GuestDrawer', () => {
vi.setSystemTime(new Date('2026-03-02T12:00:00Z'));
});
- it('shows "Today" for a backup from today', () => {
+ it('shows elapsed hours for a recent backup', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => );
expect(screen.getByText('Last completed backup')).toBeInTheDocument();
- expect(technicalDetails().getByText('Today')).toBeInTheDocument();
+ expect(technicalDetails().getByText('2 hours ago')).toBeInTheDocument();
});
- it('shows "Yesterday" for a 1-day-old backup', () => {
+ it('shows elapsed days for a 1-day-old backup', () => {
const yesterday = new Date('2026-03-01T12:00:00Z').getTime();
render(() => );
- expect(technicalDetails().getByText('Yesterday')).toBeInTheDocument();
+ expect(technicalDetails().getByText('1d ago')).toBeInTheDocument();
});
it('shows "Xd ago" for older backups', () => {
@@ -1054,7 +1054,7 @@ describe('GuestDrawer', () => {
it('applies green color for recent backups', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => );
- const ageCell = technicalDetails().getByText('Today').closest('td');
+ const ageCell = technicalDetails().getByText('2 hours ago').closest('td');
expect(ageCell).toHaveClass('text-emerald-700');
});
@@ -1080,7 +1080,7 @@ describe('GuestDrawer', () => {
it.each([
['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'],
- ['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'],
+ ['2026-03-02T10:00:00Z', '2 hours ago', 'text-emerald-700'],
])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => {
solidRender(() => (
{
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() });
expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument();
- expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700');
+ expect(technicalDetails().getByText('2 hours ago').closest('td')).toHaveClass(
+ 'text-emerald-700',
+ );
expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer);
});
diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
index 319748c473..fcb8d8675f 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
@@ -26,6 +26,7 @@ import {
getGuestDrawerMemoryReading,
getGuestDrawerGuestReadPresentation,
isGuestDrawerVM,
+ type GuestDrawerBackupPresentation,
} from './guestDrawerModel';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
@@ -59,11 +60,7 @@ interface GuestDrawerOverviewProps {
networkInterfaces: NonNullable;
nestedWorkloadContext?: NestedWorkloadContext;
normalizedTags: string[];
- backupPresentation: {
- ageClass: string;
- ageLabel: string;
- dateLabel: string;
- } | null;
+ backupPresentation: GuestDrawerBackupPresentation | null;
diskThresholds?: MetricDisplayThresholds | null;
workloadActionAgentTitle: string;
parentMemoryTotal?: GuestDrawerProps['parentMemoryTotal'];
@@ -143,7 +140,23 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
// backup. Keep its activity separate from the last completed age.
makeDetailRow('Last completed backup', protectionPresentation().label, {
tone: protectionPresentation().tone,
+ layout: 'stacked',
wrap: true,
+ valueContent: (
+ <>
+
+ {protectionPresentation().label}
+
+
+
+
+ >
+ ),
}),
makeDetailRow(
'Backup activity',
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx
new file mode 100644
index 0000000000..0bf1b7c448
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx
@@ -0,0 +1,191 @@
+import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { WorkloadGuest } from '@/types/workloads';
+import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
+import { GuestDrawer } from '../GuestDrawer';
+import { useGuestDrawerState } from '../useGuestDrawerState';
+
+const policy = vi.hoisted(() => ({ freshHours: 24, staleHours: 72 }));
+const calls = vi.hoisted(() => ({ history: vi.fn() }));
+vi.mock('@/stores/alertsActivation', () => ({
+ useAlertsActivation: () => ({
+ getBackupThresholds: () => policy,
+ getMetricThresholds: () => null,
+ }),
+}));
+vi.mock('@/api/charts', async () => {
+ const actual = await vi.importActual('@/api/charts');
+ return { ...actual, ChartsAPI: { ...actual.ChartsAPI, getMetricsHistory: calls.history } };
+});
+vi.mock('@/api/resourceOperatorState', async () => {
+ const actual = await vi.importActual(
+ '@/api/resourceOperatorState',
+ );
+ return { ...actual, getResourceOperatorState: vi.fn().mockResolvedValue(null) };
+});
+vi.mock('@/stores/license', () => ({
+ isRangeLocked: () => false,
+ loadRuntimeCapabilities: vi.fn(),
+ maxHistoryDays: () => 90,
+}));
+
+const start = Date.parse('2026-10-12T12:00:00Z');
+const hour = 3600_000;
+const guest = (type: 'qemu' | 'lxc', lastBackup: number): WorkloadGuest => ({
+ id: 'lab:pve-a:101',
+ name: 'backup-clock-fixture',
+ vmid: 101,
+ instance: 'lab',
+ node: 'pve-a',
+ type,
+ status: 'running',
+ cpu: 0.1,
+ cpus: 1,
+ memory: { total: 1024, used: 256, free: 768, usage: 25 },
+ disk: { total: 1024, used: 256, free: 768, usage: 25 },
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ tags: [],
+ lastBackup,
+ lock: '',
+ lastSeen: new Date(start).toISOString(),
+});
+const details = () => within(screen.getByTestId('guest-technical-details'));
+const backupRow = () => details().getByText('Last completed backup').closest('tr')!;
+const completion = () => backupRow().querySelector('time');
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(start);
+ policy.freshHours = 24;
+ policy.staleHours = 72;
+ calls.history.mockClear();
+});
+afterEach(() => {
+ cleanup();
+ expect(vi.getTimerCount()).toBe(0);
+ vi.useRealTimers();
+});
+
+describe('mounted completed-backup evidence', () => {
+ it('invalidates the saved age policy on time alone, independently of new copy or markup', () => {
+ function StateProbe() {
+ const state = useGuestDrawerState({
+ guest: guest('qemu', start - 24 * hour + 5_000),
+ onClose: () => {},
+ });
+ return {state.backupPresentation().ageClass};
+ }
+ render(() => );
+ expect(screen.getByTestId('age-policy')).toHaveTextContent('text-green-600');
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(screen.getByTestId('age-policy')).toHaveTextContent('text-yellow-600');
+ });
+ it.each(['qemu', 'lxc'] as const)(
+ 'ages an unchanged %s backup across the default policy boundary',
+ (type) => {
+ const timestamp = start - 24 * hour + 5_000;
+ render(() => {}} />);
+ const drawer = screen.getByTestId('guest-technical-details');
+ const row = backupRow();
+ expect(within(row).getByText('23 hours ago').closest('td')).toHaveClass('text-emerald-700');
+ const originalTime = completion();
+ expect(originalTime).toHaveAttribute('datetime', new Date(timestamp).toISOString());
+ expect(originalTime).toHaveTextContent('2026-10-11 12:00:05 UTC');
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(within(backupRow()).getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
+ expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
+ expect(screen.getByTestId('guest-technical-details')).toBe(drawer);
+ expect(calls.history).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(['qemu', 'lxc'] as const)(
+ 'ages an unchanged %s backup under a one-hour policy',
+ (type) => {
+ policy.freshHours = 1;
+ policy.staleHours = 3;
+ render(() => {}} />);
+ expect(details().getByText('59 mins ago').closest('td')).toHaveClass('text-emerald-700');
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(details().getByText('1 hour ago').closest('td')).toHaveClass('text-amber-700');
+ expect(calls.history).not.toHaveBeenCalled();
+ },
+ );
+
+ it('does not label a previous-calendar-day completion Today', () => {
+ vi.setSystemTime(Date.parse('2026-10-12T00:30:00Z'));
+ render(() => (
+ {}} />
+ ));
+ expect(details().getByText('1 hour ago')).toBeVisible();
+ expect(completion()).toHaveTextContent('2026-10-11 23:30:00 UTC');
+ expect(details().queryByText('Today')).not.toBeInTheDocument();
+ });
+
+ it('keeps activity separate and ages while another drawer tab is open', () => {
+ const timestamp = start - 24 * hour + 5_000;
+ const [current, setCurrent] = createSignal({
+ ...guest('qemu', timestamp),
+ backupInProgress: true,
+ });
+ render(() => {}} />);
+ expect(details().getByText('Running · not completed yet')).toBeVisible();
+ fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ setCurrent({ ...current(), backupInProgress: false });
+ fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
+ expect(details().getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
+ expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
+ expect(details().queryByText('Running · not completed yet')).not.toBeInTheDocument();
+ setCurrent({ ...current(), lastBackup: start + RELATIVE_TIME_TICK_MS });
+ expect(details().getByText('0s ago').closest('td')).toHaveClass('text-emerald-700');
+ expect(completion()).toHaveAttribute('datetime', '2026-10-12T12:00:30.000Z');
+ vi.advanceTimersByTime(60_000);
+ expect(details().getByText('1 min ago')).toBeVisible();
+ expect(calls.history).not.toHaveBeenCalled();
+ });
+
+ it('rejudges future and backwards-clock times without manufacturing completion', () => {
+ const timestamp = start + 45_000;
+ render(() => {}} />);
+ expect(
+ details()
+ .getByText(/timestamp is in the future/)
+ .closest('td'),
+ ).toHaveClass('text-amber-700');
+ expect(completion()).toBeNull();
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(completion()).toBeNull();
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(details().getByText('15s ago').closest('td')).toHaveClass('text-emerald-700');
+ expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
+ vi.setSystemTime(start - hour);
+ vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
+ expect(
+ details()
+ .getByText(/timestamp is in the future/)
+ .closest('td'),
+ ).toHaveClass('text-amber-700');
+ expect(completion()).toBeNull();
+ expect(details().queryByText('No completed backup found')).not.toBeInTheDocument();
+ expect(calls.history).not.toHaveBeenCalled();
+ });
+
+ it.each([0, NaN, Infinity])(
+ 'does not turn absent or invalid evidence %s into a dated completion',
+ (timestamp) => {
+ render(() => {}} />);
+ const initial = backupRow().textContent;
+ vi.advanceTimersByTime(60_000);
+ expect(backupRow()).toHaveTextContent(initial!);
+ expect(completion()).toBeNull();
+ expect(calls.history).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
index 2ffd5318e2..0e6cb0570a 100644
--- a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
@@ -686,21 +686,25 @@ describe('guestDrawerModel (branch coverage)', () => {
const expectPresentation = (days: number) => {
const lastBackup = backupNDaysAgo(days);
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
- expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
+ expect(result.dateLabel).toBe(
+ new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
+ );
+ expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
return result;
};
- it('labels day 0 as Today and uses the green (fresh) class', () => {
+ it('labels a just-completed backup with elapsed time and the green (fresh) class', () => {
expect(expectPresentation(0)).toStrictEqual({
ageClass: 'text-green-600 dark:text-green-400',
- ageLabel: 'Today',
- dateLabel: new Date(backupNDaysAgo(0)).toLocaleDateString(),
+ ageLabel: '0s ago',
+ dateLabel: now.toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
+ completedAt: now.toISOString(),
});
});
- it('labels day 1 as Yesterday', () => {
+ it('labels day 1 as elapsed days', () => {
const result = expectPresentation(1);
- expect(result.ageLabel).toBe('Yesterday');
+ expect(result.ageLabel).toBe('1d ago');
expect(result.ageClass).toBe('text-green-600 dark:text-green-400');
});
@@ -733,7 +737,10 @@ describe('guestDrawerModel (branch coverage)', () => {
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
expect(result.ageLabel).toBe('10d ago');
expect(result.ageClass).toBe('text-yellow-600 dark:text-yellow-400');
- expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
+ expect(result.dateLabel).toBe(
+ new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
+ );
+ expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
});
});
});
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index cb06222121..d9b26fdb02 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -83,6 +83,7 @@ export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
dateLabel: string;
+ completedAt?: string;
}
export const isGuestDrawerVM = (guest: Guest): guest is VM => resolveWorkloadType(guest) === 'vm';
@@ -632,7 +633,7 @@ export const normalizeGuestDrawerTags = (tags: Guest['tags']): string[] => {
export const getGuestDrawerBackupPresentation = (
lastBackup: string | number | Date | null | undefined,
thresholds?: BackupThresholds,
- now: Date = new Date(),
+ now: number | Date = Date.now(),
): GuestDrawerBackupPresentation => {
const timestamp = lastBackup instanceof Date ? lastBackup.getTime() : lastBackup;
const info = getBackupInfo(timestamp, thresholds, now);
@@ -644,12 +645,15 @@ export const getGuestDrawerBackupPresentation = (
dateLabel: 'Unknown',
};
}
- const backupDate = new Date(timestamp!);
+ const completedAt = new Date(timestamp!).toISOString();
const daysSince = Math.floor(info.ageMs / (1000 * 60 * 60 * 24));
return {
ageClass: statusPresentation.color,
- ageLabel: daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`,
- dateLabel: backupDate.toLocaleDateString(),
+ // Elapsed age, not a rolling-day calendar claim: a 23-hour-old backup
+ // need not have completed today. Keep older ages in days, like the table.
+ ageLabel: daysSince === 0 ? info.ageFormatted : `${daysSince}d ago`,
+ dateLabel: completedAt.replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'),
+ completedAt,
};
};
diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
index 8d0a49be66..e2f262ba83 100644
--- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
+++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
@@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal } from 'solid-js';
import type { HistoryTimeRange } from '@/api/charts';
import { useAlertsActivation } from '@/stores/alertsActivation';
+import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import type { DisplayMetricType } from '@/utils/metricThresholds';
import {
getCanonicalWorkloadId,
@@ -36,6 +37,7 @@ import {
export function useGuestDrawerState(props: GuestDrawerProps) {
const alertsActivation = useAlertsActivation();
+ const now = useRelativeTimeNow();
const [activeTab, setActiveTab] = createSignal('overview');
const [historyRange, setHistoryRange] = createSignal(
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
@@ -84,6 +86,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
getGuestDrawerBackupPresentation(
props.guest.lastBackup,
alertsActivation.getBackupThresholds(),
+ now(),
),
);
const historyTarget = createMemo(() => getGuestDrawerHistoryTarget(props.guest));
diff --git a/internal/monitoring/pve_session_recovery_test.go b/internal/monitoring/pve_session_recovery_test.go
new file mode 100644
index 0000000000..fc8d0b8ab8
--- /dev/null
+++ b/internal/monitoring/pve_session_recovery_test.go
@@ -0,0 +1,107 @@
+package monitoring
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "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/unifiedresources"
+ "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
+)
+
+// Exercise the changed password client through the real parallel node poller,
+// registry and frontend projection. No native QGA/SSH, backup or database is
+// used. The emitted synthetic resources also feed the retained pixel proof.
+func TestPVESessionRenewalProducesTrustedNodeReadback(t *testing.T) {
+ const count = 8
+ var authCalls, rejectedStatus, acceptedStatus, agentCalls atomic.Int32
+ allOldReads := make(chan struct{})
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/access/ticket"):
+ n := authCalls.Add(1)
+ fmt.Fprintf(w, `{"data":{"ticket":"synthetic-session-%d","CSRFPreventionToken":"synthetic-csrf-%d"}}`, n, n)
+ case r.URL.Path == "/api2/json/nodes":
+ nodes := make([]proxmox.Node, count)
+ for i := range nodes {
+ nodes[i] = proxmox.Node{Node: fmt.Sprintf("session-node-%02d", i+1), Status: "online", CPU: .05, MaxCPU: 4, MaxMem: 2 << 30, Mem: 1 << 30, MaxDisk: 100 << 30, Disk: 25 << 30, Uptime: 7200}
+ }
+ json.NewEncoder(w).Encode(map[string]any{"data": nodes})
+ case strings.HasSuffix(r.URL.Path, "/status"):
+ cookie, err := r.Cookie("PVEAuthCookie")
+ if err != nil {
+ t.Error("status missing synthetic session")
+ w.WriteHeader(401)
+ return
+ }
+ if cookie.Value == "synthetic-session-1" {
+ if rejectedStatus.Add(1) == count {
+ close(allOldReads)
+ }
+ select {
+ case <-allOldReads:
+ case <-ctx.Done():
+ return
+ }
+ w.WriteHeader(http.StatusUnauthorized)
+ fmt.Fprint(w, `{"message":"synthetic expired session"}`)
+ return
+ }
+ acceptedStatus.Add(1)
+ fmt.Fprint(w, `{"data":{"cpu":0.05,"memory":{"total":2147483648,"used":536870912,"free":1073741824,"available":1610612736},"rootfs":{"total":107374182400,"used":26843545600,"free":80530636800},"uptime":7200,"loadavg":[0.1,0.1,0.1],"kversion":"fixture-kernel","pveversion":"pve-manager/9.2.21/fixture","cpuinfo":{"model":"Synthetic CPU","cores":4,"sockets":1,"mhz":"2000"}}}`)
+ case strings.Contains(r.URL.Path, "/agent/"):
+ agentCalls.Add(1)
+ t.Error("node recovery queued guest work")
+ w.WriteHeader(500)
+ default:
+ fmt.Fprint(w, `{"data":[]}`) // ordinary network/apt inventory only
+ }
+ }))
+ defer server.Close()
+ client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, User: "synthetic@pam", Password: "synthetic-password", Timeout: 5 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rawNodes, err := client.GetNodes(ctx)
+ if err != nil || len(rawNodes) != count {
+ t.Fatalf("ordinary node discovery: %v", err)
+ }
+ instance := config.PVEInstance{Name: "session-fixture", Host: server.URL, IsCluster: true, ClusterName: "Session recovery"}
+ m := &Monitor{config: &config.Config{PVEInstances: []config.PVEInstance{instance}}, nodeLastOnline: make(map[string]time.Time)}
+ nodes, statuses, _ := m.pollPVENodesParallel(ctx, instance.Name, &instance, client, rawNodes, "healthy", nil, false)
+ if len(nodes) != count || len(statuses) != count || authCalls.Load() != 2 || rejectedStatus.Load() != count || acceptedStatus.Load() != count || agentCalls.Load() != 0 {
+ t.Fatalf("node recovery counts nodes/statuses/auth/rejected/accepted/guest=%d/%d/%d/%d/%d/%d", len(nodes), len(statuses), authCalls.Load(), rejectedStatus.Load(), acceptedStatus.Load(), agentCalls.Load())
+ }
+ for _, node := range nodes {
+ if node.Status != "online" || node.ConnectionHealth != "healthy" || !node.Memory.HasKnownUsage() || node.Memory.Usage != 25 || node.Disk.Usage != 25 {
+ t.Fatalf("session recovery lost trusted ordinary fields: %+v", node)
+ }
+ }
+ registry := unifiedresources.NewRegistry(nil)
+ registry.IngestSnapshot(models.StateSnapshot{Nodes: nodes})
+ resources, _ := convertPresentationResourcesForBroadcast(registry.ListForPresentation())
+ if len(resources) != count {
+ t.Fatal("node projection lost recovered nodes")
+ }
+ for _, resource := range resources {
+ if resource.Memory == nil || resource.Memory.Current != 25 || resource.CPU == nil || resource.CPU.Current != 5 {
+ t.Fatalf("rendered projection changed cache-aware memory/CPU: %+v", resource)
+ }
+ }
+ payload, err := json.Marshal(map[string]any{"resources": resources, "authentications": authCalls.Load(), "rejectedStatusReads": rejectedStatus.Load(), "acceptedStatusReads": acceptedStatus.Load(), "guestCommands": agentCalls.Load()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("PULSE_SESSION_RECOVERY_JSON=%s", payload)
+}
diff --git a/pkg/proxmox/auth_session_concurrency_test.go b/pkg/proxmox/auth_session_concurrency_test.go
new file mode 100644
index 0000000000..cd83e654ec
--- /dev/null
+++ b/pkg/proxmox/auth_session_concurrency_test.go
@@ -0,0 +1,325 @@
+package proxmox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// All reads have already left with the same rejected session before renewal
+// can start. Late 401s must reuse the new session, not invalidate it again.
+// The fixture issues synthetic tickets only and never queues guest work.
+func TestClientSessionConcurrent401(t *testing.T) {
+ const readers = 16
+ var authentications, oldRequests, newRequests, mismatchedPairs atomic.Int32
+ oldArrived := make(chan struct{})
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access/ticket") {
+ n := authentications.Add(1)
+ fmt.Fprintf(w, `{"data":{"ticket":"session-%d","CSRFPreventionToken":"csrf-%d"}}`, n, n)
+ return
+ }
+ cookie, err := r.Cookie("PVEAuthCookie")
+ if err != nil {
+ t.Error("request missing synthetic session")
+ w.WriteHeader(401)
+ return
+ }
+ if cookie.Value == "session-1" {
+ if oldRequests.Add(1) == readers {
+ close(oldArrived)
+ }
+ select {
+ case <-oldArrived:
+ case <-ctx.Done():
+ return
+ }
+ w.WriteHeader(http.StatusUnauthorized)
+ fmt.Fprint(w, `{"message":"synthetic session expired"}`)
+ return
+ }
+ if r.Method != http.MethodGet {
+ n := strings.TrimPrefix(cookie.Value, "session-")
+ if r.Header.Get("CSRFPreventionToken") != "csrf-"+n {
+ mismatchedPairs.Add(1)
+ w.WriteHeader(403)
+ return
+ }
+ }
+ newRequests.Add(1)
+ fmt.Fprint(w, `{"data":[{"node":"session-safe-node","status":"online","cpu":0.05,"maxcpu":4,"mem":268435456,"maxmem":2147483648}]}`)
+ }))
+ defer server.Close()
+ c, err := NewClient(ClientConfig{Host: server.URL, User: "synthetic@pam", Password: "synthetic-password", Timeout: 5 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.httpClient.CloseIdleConnections()
+ errors := make(chan error, readers)
+ var wg sync.WaitGroup
+ for i := 0; i < readers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ nodes, err := c.GetNodes(ctx)
+ if err == nil && (len(nodes) != 1 || nodes[0].Node != "session-safe-node" || nodes[0].Status != "online") {
+ err = fmt.Errorf("renewed session lost ordinary node result")
+ }
+ errors <- err
+ }()
+ }
+ wg.Wait()
+ close(errors)
+ for err := range errors {
+ if err != nil {
+ t.Error(err)
+ }
+ }
+ // Check ticket/CSRF pairing through the same production request path.
+ for i := 0; i < readers; i++ {
+ resp, err := c.request(ctx, http.MethodPost, "/synthetic-read", nil)
+ if err != nil {
+ t.Error(err)
+ continue
+ }
+ _, err = readResponseBodyLimited(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ t.Error(err)
+ }
+ }
+ if got := authentications.Load(); got != 2 {
+ t.Errorf("session-renewal stampede: authentications=%d, want initial plus one renewal", got)
+ }
+ if oldRequests.Load() != readers || newRequests.Load() != 2*readers || mismatchedPairs.Load() != 0 {
+ t.Errorf("old/new/pair-mismatch=%d/%d/%d, want %d/%d/0", oldRequests.Load(), newRequests.Load(), mismatchedPairs.Load(), readers, 2*readers)
+ }
+ t.Log("ordinary reads=" + strconv.Itoa(readers) + "; one shared renewal required; coherent ticket/CSRF pairs checked")
+}
+
+// Done signals the exact point at which a caller joins a pending renewal.
+// There is no sleep or production-only instrumentation in these controls.
+type authJoinContext struct {
+ context.Context
+ joined chan struct{}
+ once sync.Once
+}
+
+func (c *authJoinContext) Done() <-chan struct{} {
+ c.once.Do(func() { close(c.joined) })
+ return c.Context.Done()
+}
+
+func awaitSessionSignal(t *testing.T, signal <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-signal:
+ case <-time.After(5 * time.Second):
+ t.Fatal("session fixture barrier did not complete")
+ }
+}
+
+func expireSessionFixture(c *Client) {
+ c.authMu.Lock()
+ c.auth.expiresAt = time.Now().Add(-time.Hour)
+ c.authMu.Unlock()
+}
+
+func TestClientSessionConcurrentExpiryAndFailureRecovery(t *testing.T) {
+ for _, failRenewal := range []bool{false, true} {
+ t.Run(fmt.Sprintf("failure=%t", failRenewal), func(t *testing.T) {
+ const readers = 8
+ entered, finish := make(chan struct{}), make(chan struct{})
+ var authCalls, apiCalls atomic.Int32
+ var fail atomic.Bool
+ fail.Store(failRenewal)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access/ticket") {
+ n := authCalls.Add(1)
+ if n == 2 {
+ close(entered)
+ select {
+ case <-finish:
+ case <-ctx.Done():
+ return
+ }
+ if fail.Load() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ fmt.Fprint(w, "synthetic renewal unavailable")
+ return
+ }
+ }
+ fmt.Fprintf(w, `{"data":{"ticket":"session-%d","CSRFPreventionToken":"csrf-%d"}}`, n, n)
+ return
+ }
+ apiCalls.Add(1)
+ fmt.Fprint(w, `{"data":[{"node":"session-safe-node","status":"online"}]}`)
+ }))
+ defer server.Close()
+ c, err := NewClient(ClientConfig{Host: server.URL, User: "synthetic@pam", Password: "synthetic-password", Timeout: 5 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.httpClient.CloseIdleConnections()
+ expireSessionFixture(c)
+ done := make(chan error, readers)
+ go func() { _, err := c.GetNodes(ctx); done <- err }()
+ awaitSessionSignal(t, entered)
+ for i := 1; i < readers; i++ {
+ joined := &authJoinContext{Context: ctx, joined: make(chan struct{})}
+ go func() { _, err := c.GetNodes(joined); done <- err }()
+ awaitSessionSignal(t, joined.joined)
+ }
+ if authCalls.Load() != 2 || apiCalls.Load() != 0 {
+ t.Errorf("expired readers bypassed shared renewal: auth/api=%d/%d", authCalls.Load(), apiCalls.Load())
+ }
+ close(finish)
+ for i := 0; i < readers; i++ {
+ err := <-done
+ if failRenewal {
+ if err == nil || !strings.Contains(err.Error(), "synthetic renewal unavailable") {
+ t.Errorf("renewal failure not shared: %v", err)
+ }
+ } else if err != nil {
+ t.Error(err)
+ }
+ }
+ if authCalls.Load() != 2 {
+ t.Error("joined callers retried completed renewal")
+ }
+ if failRenewal {
+ snapshot, _ := c.authSnapshot()
+ if snapshot.ticket != "session-1" || snapshot.csrfToken != "csrf-1" || !snapshot.expiresAt.Before(time.Now()) {
+ t.Error("failed renewal published or refreshed the old session")
+ }
+ if apiCalls.Load() != 0 {
+ t.Error("failed authentication admitted API reads")
+ }
+ // A new ordinary call may recover; the failed attempt is not
+ // automatically replayed by any of its joined callers.
+ fail.Store(false)
+ if nodes, err := c.GetNodes(ctx); err != nil || len(nodes) != 1 {
+ t.Errorf("ordinary later recovery failed: %v", err)
+ }
+ if authCalls.Load() != 3 || apiCalls.Load() != 1 {
+ t.Errorf("later recovery counts auth/api=%d/%d", authCalls.Load(), apiCalls.Load())
+ }
+ } else if apiCalls.Load() != readers {
+ t.Errorf("renewed session lost %d reads", readers-apiCalls.Load())
+ }
+ })
+ }
+}
+
+func TestClientSessionRenewalWaiterCancellation(t *testing.T) {
+ entered, finish := make(chan struct{}), make(chan struct{})
+ var authCalls, apiCalls atomic.Int32
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access/ticket") {
+ n := authCalls.Add(1)
+ if n == 2 {
+ close(entered)
+ select {
+ case <-finish:
+ case <-ctx.Done():
+ return
+ }
+ }
+ fmt.Fprintf(w, `{"data":{"ticket":"session-%d","CSRFPreventionToken":"csrf-%d"}}`, n, n)
+ return
+ }
+ apiCalls.Add(1)
+ fmt.Fprint(w, `{"data":[]}`)
+ }))
+ defer server.Close()
+ c, err := NewClient(ClientConfig{Host: server.URL, User: "synthetic@pam", Password: "synthetic-password", Timeout: 5 * time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.httpClient.CloseIdleConnections()
+ expireSessionFixture(c)
+ owner := make(chan error, 1)
+ go func() { _, err := c.GetNodes(ctx); owner <- err }()
+ awaitSessionSignal(t, entered)
+ waiterCtx, cancelWaiter := context.WithCancel(ctx)
+ defer cancelWaiter()
+ joined := &authJoinContext{Context: waiterCtx, joined: make(chan struct{})}
+ waiter := make(chan error, 1)
+ go func() { _, err := c.GetNodes(joined); waiter <- err }()
+ awaitSessionSignal(t, joined.joined)
+ cancelWaiter()
+ select {
+ case err := <-waiter:
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf("cancelled waiter result: %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("cancelled waiter remained blocked behind another caller's renewal")
+ }
+ if authCalls.Load() != 2 || apiCalls.Load() != 0 {
+ t.Error("cancelled waiter replayed renewal or admitted a read")
+ }
+ select {
+ case err := <-owner:
+ t.Fatalf("waiter cancellation ended the owner's renewal: %v", err)
+ default:
+ }
+ close(finish)
+ if err := <-owner; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.GetNodes(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if authCalls.Load() != 2 || apiCalls.Load() != 2 {
+ t.Errorf("owner/later recovery counts auth/api=%d/%d", authCalls.Load(), apiCalls.Load())
+ }
+}
+
+func TestClientSessionTokenAuthDoesNotRenew(t *testing.T) {
+ var authCalls, apiCalls atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/access/ticket") {
+ authCalls.Add(1)
+ w.WriteHeader(500)
+ return
+ }
+ apiCalls.Add(1)
+ if r.Header.Get("Authorization") != "PVEAPIToken=synthetic@pve!pulse=synthetic-token" || r.Header.Get("Cookie") != "" || r.Header.Get("CSRFPreventionToken") != "" {
+ t.Error("token read acquired session headers")
+ }
+ fmt.Fprint(w, `{"data":[]}`)
+ }))
+ defer server.Close()
+ c, err := NewClient(ClientConfig{Host: server.URL, TokenName: "synthetic@pve!pulse", TokenValue: "synthetic-token", Password: "unused-synthetic-password", Timeout: time.Second})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.httpClient.CloseIdleConnections()
+ done := make(chan error, 16)
+ for i := 0; i < 16; i++ {
+ go func() { _, err := c.GetNodes(context.Background()); done <- err }()
+ }
+ for i := 0; i < 16; i++ {
+ if err := <-done; err != nil {
+ t.Error(err)
+ }
+ }
+ if authCalls.Load() != 0 || apiCalls.Load() != 16 {
+ t.Errorf("token auth/API calls=%d/%d", authCalls.Load(), apiCalls.Load())
+ }
+}
diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go
index 47f6a75a43..9613d2cca2 100644
--- a/pkg/proxmox/client.go
+++ b/pkg/proxmox/client.go
@@ -11,6 +11,7 @@ import (
"net/url"
"strconv"
"strings"
+ "sync"
"time"
"unicode"
@@ -255,6 +256,24 @@ type Client struct {
httpClient *http.Client
auth auth
config ClientConfig
+
+ // A polling client is shared by concurrent node and guest reads. Publish
+ // each ticket/CSRF/expiry tuple atomically and share only session renewal,
+ // not the subsequent API requests or their caller-owned cancellation.
+ authMu sync.Mutex
+ authGeneration uint64
+ authInFlight *authRefresh
+}
+
+type authRefresh struct {
+ done chan struct{}
+ err error
+}
+
+func (c *Client) authSnapshot() (auth, uint64) {
+ c.authMu.Lock()
+ defer c.authMu.Unlock()
+ return c.auth, c.authGeneration
}
func (c *Client) apiBaseURL() (*url.URL, error) {
@@ -398,16 +417,47 @@ func NewClient(cfg ClientConfig) (*Client, error) {
// authenticate performs password-based authentication
func (c *Client) authenticate(ctx context.Context) error {
- username := c.auth.user + "@" + c.auth.realm
- password := c.config.Password
+ return c.renewAuthentication(ctx, nil)
+}
- if err := c.authenticateJSON(ctx, username, password); err == nil {
- return nil
- } else if shouldFallbackToForm(err) {
- return c.authenticateForm(ctx, username, password)
- } else {
+// expected binds expiry or a 401 to the session actually used by that read.
+// A late rejection of an old ticket cannot trigger another renewal after a
+// concurrent request has already replaced it. Explicit authentication (nil)
+// still owns the existing JSON/form fallback and failed-attempt behaviour.
+func (c *Client) renewAuthentication(ctx context.Context, expected *uint64) error {
+ if err := ctx.Err(); err != nil {
return err
}
+ c.authMu.Lock()
+ if expected != nil && c.authGeneration != *expected {
+ c.authMu.Unlock()
+ return nil
+ }
+ if pending := c.authInFlight; pending != nil {
+ c.authMu.Unlock()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-pending.done:
+ return pending.err
+ }
+ }
+ pending := &authRefresh{done: make(chan struct{})}
+ c.authInFlight = pending
+ username := c.auth.user + "@" + c.auth.realm
+ c.authMu.Unlock()
+
+ password := c.config.Password
+ err := c.authenticateJSON(ctx, username, password)
+ if shouldFallbackToForm(err) {
+ err = c.authenticateForm(ctx, username, password)
+ }
+ c.authMu.Lock()
+ pending.err = err
+ c.authInFlight = nil
+ close(pending.done)
+ c.authMu.Unlock()
+ return err
}
func (c *Client) authenticateJSON(ctx context.Context, username, password string) error {
@@ -488,9 +538,12 @@ func (c *Client) handleAuthResponse(resp *http.Response) error {
return err
}
+ c.authMu.Lock()
c.auth.ticket = result.Data.Ticket
c.auth.csrfToken = result.Data.CSRFPreventionToken
c.auth.expiresAt = time.Now().Add(2 * time.Hour) // PVE tickets expire after 2 hours
+ c.authGeneration++
+ c.authMu.Unlock()
return nil
}
@@ -528,10 +581,12 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, data
func (c *Client) requestWithRetryUsingClient(ctx context.Context, method, path string, data url.Values, retriedAfter401 bool, httpClient *http.Client) (*http.Response, error) {
// Re-authenticate if needed
- if c.config.Password != "" && c.auth.tokenName == "" && time.Now().After(c.auth.expiresAt) {
- if err := c.authenticate(ctx); err != nil {
+ auth, generation := c.authSnapshot()
+ if c.config.Password != "" && auth.tokenName == "" && time.Now().After(auth.expiresAt) {
+ if err := c.renewAuthentication(ctx, &generation); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
+ auth, generation = c.authSnapshot()
}
var body io.Reader
@@ -554,23 +609,23 @@ func (c *Client) requestWithRetryUsingClient(ctx context.Context, method, path s
}
// Set authentication
- if c.auth.tokenName != "" && c.auth.tokenValue != "" {
+ if auth.tokenName != "" && auth.tokenValue != "" {
// API token authentication
authHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=%s",
- c.auth.user, c.auth.realm, c.auth.tokenName, c.auth.tokenValue)
+ auth.user, auth.realm, auth.tokenName, auth.tokenValue)
req.Header.Set("Authorization", authHeader)
// NEVER log the actual token value - only log that we're using token auth
maskedHeader := fmt.Sprintf("PVEAPIToken=%s@%s!%s=***",
- c.auth.user, c.auth.realm, c.auth.tokenName)
+ auth.user, auth.realm, auth.tokenName)
log.Debug().
Str("authHeader", maskedHeader).
Str("url", req.URL.String()).
Msg("Setting API token authentication")
- } else if c.auth.ticket != "" {
+ } else if auth.ticket != "" {
// Ticket authentication
- req.Header.Set("Cookie", "PVEAuthCookie="+c.auth.ticket)
- if method != "GET" && c.auth.csrfToken != "" {
- req.Header.Set("CSRFPreventionToken", c.auth.csrfToken)
+ req.Header.Set("Cookie", "PVEAuthCookie="+auth.ticket)
+ if method != "GET" && auth.csrfToken != "" {
+ req.Header.Set("CSRFPreventionToken", auth.csrfToken)
}
}
@@ -589,8 +644,8 @@ func (c *Client) requestWithRetryUsingClient(ctx context.Context, method, path s
// Password sessions can be invalidated server-side before local expiry.
// Retry once after forcing re-authentication on 401.
- if resp.StatusCode == http.StatusUnauthorized && !retriedAfter401 && c.config.Password != "" && c.auth.tokenName == "" {
- if err := c.authenticate(ctx); err != nil {
+ if resp.StatusCode == http.StatusUnauthorized && !retriedAfter401 && c.config.Password != "" && auth.tokenName == "" {
+ if err := c.renewAuthentication(ctx, &generation); err != nil {
return nil, fmt.Errorf("re-authentication failed after 401: %w", err)
}
return c.requestWithRetryUsingClient(ctx, method, path, data, true, httpClient)
@@ -600,7 +655,7 @@ func (c *Client) requestWithRetryUsingClient(ctx context.Context, method, path s
var apiErr error
if resp.StatusCode == 403 && c.config.TokenName != "" {
// Special case for 403 with API token - this is usually a permission issue
- apiErr = fmt.Errorf("API error 403 (Forbidden): The API token does not have sufficient permissions. Note: In Proxmox GUI, permissions must be set on the USER (not just the token). Please verify the user '%s@%s' has the required permissions", c.auth.user, c.auth.realm)
+ apiErr = fmt.Errorf("API error 403 (Forbidden): The API token does not have sufficient permissions. Note: In Proxmox GUI, permissions must be set on the USER (not just the token). Please verify the user '%s@%s' has the required permissions", auth.user, auth.realm)
} else if resp.StatusCode == 595 {
// 595 can mean authentication failed OR trying to access an offline node in a cluster
// Check if this is a node-specific endpoint
diff --git a/scripts/install.sh b/scripts/install.sh
index 543578ece3..91bf5cb06e 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1344,6 +1344,7 @@ teardown_openrc_agent_service() {
# cannot establish that after an ignored signal or lost PID file.
read_legacy_agent_removal_processes() {
local observation="" line="" pid="" parent="" comm="" args="" role=""
+ local bootstrap="${1:-}"
local complete="false" count=0 parents=" " seen=" "
local -a rows=()
observation=$(LC_ALL=C timeout -k 1 5 ps -ax -o pid= -o ppid= -o comm= -o args= 2>&1 &&
@@ -1365,6 +1366,7 @@ read_legacy_agent_removal_processes() {
elif [[ "${comm##*/}" == daemon && "$args" =~ (^|[[:space:]/])pulse-agent([[:space:]\[]|$) ]]; then
role="supervisor"
fi
+ if [[ -n "$bootstrap" && "$args" == *"$bootstrap"* ]]; then role="bootstrap"; fi
rows+=("$pid $parent $role ${comm##*/}")
count=$((count + 1))
[[ $count -le 65536 ]] || return 1
@@ -1706,6 +1708,132 @@ enable_sysv_agent_service() {
log_info "Created rc.d symlinks manually."
}
+# TrueNAS SCALE has a second activation owner: middleware's boot task can
+# recreate and enable the systemd unit even after systemctl disable. Recovered
+# state directories alone are not appliance identity (ordinary Linux has them).
+truenas_scale_agent_removal_present() {
+ [[ "$(uname -s)" == Linux ]] || return 1
+ [[ -e "${TRUENAS_BOOTSTRAP_SCRIPT:-}" || -L "${TRUENAS_BOOTSTRAP_SCRIPT:-}" ||
+ -f /etc/truenas-version || -d /data/ix-applications || -d /etc/ix-apps.d ]] && return 0
+ [[ -f /etc/version ]] && grep -qi truenas /etc/version
+}
+
+read_truenas_agent_removal_tasks() {
+ local observation=""
+ observation=$(LC_ALL=C timeout -k 1 5 midclt call initshutdownscript.query 2>&1 &&
+ printf '\n%s' '__pulse_truenas_tasks_complete__') || return 1
+ # Parse the complete inventory, including indirect/other-install bootstrap
+ # references. No task command, name, argument or credential is returned.
+ python3 -c '
+import json, pathlib, sys
+def unique_object(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result: raise ValueError("duplicate field")
+ result[key] = value
+ return result
+def reject_constant(value):
+ raise ValueError("non-JSON constant")
+try:
+ raw = sys.stdin.buffer.read(1048577)
+ suffix = b"\n__pulse_truenas_tasks_complete__\n"
+ if len(raw) > 1048576 or not raw.endswith(suffix): raise ValueError("incomplete inventory")
+ tasks = json.loads(raw[:-len(suffix)].decode("utf-8"), object_pairs_hook=unique_object,
+ parse_constant=reject_constant)
+ if not isinstance(tasks, list) or len(tasks) > 512: raise ValueError("unbounded inventory")
+ expected, seen, owned = sys.argv[1], set(), []
+ for task in tasks:
+ if not isinstance(task, dict): raise ValueError("invalid task")
+ ident, kind, enabled = task.get("id"), task.get("type"), task.get("enabled")
+ if type(ident) is not int or not 0 < ident <= 2147483647 or ident in seen:
+ raise ValueError("invalid identity")
+ seen.add(ident)
+ if kind not in ("SCRIPT", "COMMAND") or type(enabled) is not bool:
+ raise ValueError("unknown activation")
+ script, command = task.get("script", ""), task.get("command", "")
+ if script is None: script = ""
+ if command is None: command = ""
+ if not isinstance(script, str) or not isinstance(command, str): raise ValueError("invalid activation")
+ if (kind == "SCRIPT" and not script) or (kind == "COMMAND" and not command):
+ raise ValueError("missing activation target")
+ if script == expected:
+ if (kind != "SCRIPT" or command or task.get("when") != "POSTINIT" or
+ task.get("comment") != "Pulse Agent Bootstrap" or type(task.get("timeout")) is not int or
+ task["timeout"] != 30): raise ValueError("unowned bootstrap")
+ owned.append((ident, enabled))
+ elif (pathlib.PurePosixPath(script).name == "bootstrap-pulse-agent.sh" or
+ "bootstrap-pulse-agent.sh" in command or "pulse-agent" in command):
+ raise ValueError("indirect or other-install activation")
+ for ident, enabled in sorted(owned): print(ident, "true" if enabled else "false")
+ print("complete")
+except (ValueError, TypeError, KeyError, UnicodeError):
+ print("TrueNAS boot-task inventory is incomplete or not owned; recovery retained.", file=sys.stderr)
+ sys.exit(1)
+' "$TRUENAS_BOOTSTRAP_SCRIPT" <<< "$observation"
+}
+
+quiesce_truenas_agent_boot_tasks_for_removal() {
+ local inventory="" expected="" observed="" ident="" enabled="" mode=""
+ command -v midclt >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1 &&
+ command -v timeout >/dev/null 2>&1 && command -v systemctl >/dev/null 2>&1 || return 1
+ # Do not let a platform marker or an arbitrary saved state path authorise
+ # erasure of a different appliance install. Keep the existing state owner.
+ [[ "$TRUENAS_STATE_DIR" == "$STATE_DIR" &&
+ "${STATE_DIR_REMOVAL_AUTHORITY:-}" == "$STATE_DIR" &&
+ "$TRUENAS_BOOTSTRAP_SCRIPT" == "$STATE_DIR/bootstrap-pulse-agent.sh" ]] || return 1
+ if [[ -e "$TRUENAS_BOOTSTRAP_SCRIPT" || -L "$TRUENAS_BOOTSTRAP_SCRIPT" ]]; then
+ mode=$(portable_path_mode "$TRUENAS_BOOTSTRAP_SCRIPT") || return 1
+ [[ "$mode" =~ ^[0-7]{3,4}$ ]] && (( (8#$mode & 0022) == 0 )) || return 1
+ trusted_lifecycle_regular_file "$TRUENAS_BOOTSTRAP_SCRIPT" "$mode" || return 1
+ fi
+ inventory=$(read_truenas_agent_removal_tasks) || return 1
+ while read -r ident enabled; do
+ [[ "$ident" != complete ]] || continue
+ if [[ "$enabled" == true ]]; then
+ LC_ALL=C timeout -k 1 5 midclt call initshutdownscript.update "$ident" '{"enabled":false}' >/dev/null 2>&1 || return 1
+ fi
+ expected+="$ident false"$'\n'
+ done <<< "$inventory"
+ expected+=complete
+ observed=$(read_truenas_agent_removal_tasks) || return 1
+ [[ "$observed" == "$expected" ]] || return 1
+ printf '%s\n' "$expected"
+}
+
+confirm_truenas_agent_removal_quiescent() {
+ local unit="" inventory="" observed=""
+ for unit in "${ACTION_RUNNER_NAME}.service" "${PRIVILEGED_HELPER_NAME}.socket" \
+ "${PRIVILEGED_HELPER_NAME}.service" "${AGENT_NAME}.service"; do
+ confirm_agent_unit_quiescent "$unit" || return 1
+ done
+ inventory=$(read_legacy_agent_removal_processes "$TRUENAS_BOOTSTRAP_SCRIPT") || return 1
+ legacy_agent_processes_absent "$inventory" && [[ "$inventory" != *' bootstrap'* ]] || return 1
+ # The middleware set can change during the preceding systemd operations.
+ # Close it again before remote removal/first deletion, not only afterwards.
+ if [[ $# -gt 0 ]]; then
+ observed=$(read_truenas_agent_removal_tasks) || return 1
+ [[ "$observed" == "$1" ]] || return 1
+ fi
+}
+
+teardown_truenas_agent_boot_tasks() {
+ local expected="$1" observed="" ident="" enabled=""
+ # No newly discovered task is disabled/deleted after remote removal. A
+ # changed set, refused read or concurrent activation retains local recovery.
+ observed=$(read_truenas_agent_removal_tasks) || return 1
+ [[ "$observed" == "$expected" ]] || return 1
+ confirm_truenas_agent_removal_quiescent "$expected" || return 1
+ while read -r ident enabled; do
+ [[ "$ident" != complete ]] || continue
+ LC_ALL=C timeout -k 1 5 midclt call initshutdownscript.delete "$ident" >/dev/null 2>&1 || return 1
+ done <<< "$expected"
+ # Close the whole manager/process/task set after the last deletion, while
+ # every credential, bootstrap, stored binary and service file still exists.
+ confirm_truenas_agent_removal_quiescent || return 1
+ observed=$(read_truenas_agent_removal_tasks) || return 1
+ [[ "$observed" == complete ]]
+}
+
write_truenas_bootstrap_script() {
local platform="$1"
local service_link=""
@@ -5999,6 +6127,8 @@ if [[ "$UNINSTALL" == "true" ]]; then
local uninstall_has_credential="false"
local uninstall_legacy_manager=""
local uninstall_upstart="false"
+ local uninstall_truenas_scale="false"
+ local uninstall_truenas_tasks=""
# Validate saved connection availability before stopping a working install.
# Stop every systemd activation source before remote removal, process kills
@@ -6009,6 +6139,14 @@ if [[ "$UNINSTALL" == "true" ]]; then
fail "The saved Pulse URL is missing; local credentials and services were retained. Restore saved connection state or provide --url for the same trusted Pulse instance and retry uninstall." "$EXIT_MISSING_ARGS"
fi
fi
+ # Middleware can recreate and enable the systemd unit at boot. Disable all
+ # exact owned tasks before stopping services; a partial/refused observation
+ # retains every recovery file and never restores automatic activation.
+ if truenas_scale_agent_removal_present; then
+ uninstall_truenas_scale="true"
+ uninstall_truenas_tasks=$(quiesce_truenas_agent_boot_tasks_for_removal) ||
+ fail "TrueNAS SCALE removal could not confirm every owned boot task disabled; credentials, bootstrap and service files were retained. Reconcile middleware task ownership/state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
+ fi
# Detect stale DSM 6 jobs even on a DSM 7 host. Another init manager cannot
# establish their absence; missing initctl is an explicit safe refusal.
if [[ -d /usr/syno ]] && { command -v initctl >/dev/null 2>&1 ||
@@ -6037,6 +6175,10 @@ if [[ "$UNINSTALL" == "true" ]]; then
quiesce_sysv_agent_for_removal ||
fail "SysV removal could not confirm stopped process and disabled runlevels; local recovery files were retained. A preceding stop or disable may have succeeded; no automatic restart was attempted." "$EXIT_GENERAL"
fi
+ if [[ "$uninstall_truenas_scale" == true ]]; then
+ confirm_truenas_agent_removal_quiescent "$uninstall_truenas_tasks" ||
+ fail "TrueNAS SCALE removal could not confirm stopped units, collector and bootstrap processes; local recovery was retained. Reconcile stopped/disabled state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
+ fi
# A credential-bearing install must durably remove its exact server record
# through the same CA/fingerprint/no-proxy transport used by lifecycle
@@ -6068,6 +6210,10 @@ if [[ "$UNINSTALL" == "true" ]]; then
teardown_upstart_agent_service ||
fail "DSM Upstart job retirement was not confirmed; local recovery state was retained. The manual override remains after a partial stop. Reconcile the original config or its .removal recovery copy before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
fi
+ if [[ "$uninstall_truenas_scale" == true ]]; then
+ teardown_truenas_agent_boot_tasks "$uninstall_truenas_tasks" ||
+ fail "TrueNAS SCALE boot-task retirement was not confirmed; local recovery was retained. A preceding task disable/delete or server removal may have succeeded. Reconcile middleware and stopped service state before retrying; no automatic restart was attempted." "$EXIT_GENERAL"
+ fi
# Kill wrapper scripts first: they are watchdogs, so stopping the agent
# while its wrapper still loops only races the respawn. No leading path
@@ -6181,15 +6327,12 @@ if [[ "$UNINSTALL" == "true" ]]; then
fi
fi
- # TrueNAS SCALE/CORE
- if [[ -d "$TRUENAS_STATE_DIR" ]] || [[ -f /etc/truenas-version ]] || [[ -f /etc/version ]]; then
- if [[ "$(uname -s)" == "Linux" ]]; then
- log_info "Removing TrueNAS SCALE installation..."
- teardown_systemd_agent_service
- elif [[ "$(uname -s)" == "FreeBSD" ]]; then
- log_info "Removing TrueNAS CORE installation..."
- # rc.d teardown was confirmed before remote/local recovery erasure.
- fi
+ # SCALE's task retirement was confirmed above, before authorised state
+ # erasure. A recovered ordinary Linux directory is not a TrueNAS install.
+ # CORE remains behind its existing rc.d/appliance activation refusal; this
+ # SCALE step does not claim CORE boot-task or native platform acceptance.
+ if [[ "$(uname -s)" == "FreeBSD" ]] && { [[ -d "$TRUENAS_STATE_DIR" ]] || [[ -f /etc/truenas-version ]] || [[ -f /etc/version ]]; }; then
+ log_info "Removing TrueNAS CORE installation..."
# Remove Init/Shutdown task
if command -v midclt >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
TASK_ID=$(midclt call initshutdownscript.query '[["script","=","'"$TRUENAS_BOOTSTRAP_SCRIPT"'"]]' 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "")
diff --git a/scripts/installtests/agent_legacy_teardown.py b/scripts/installtests/agent_legacy_teardown.py
index 54afa1b99c..51dc2a0587 100644
--- a/scripts/installtests/agent_legacy_teardown.py
+++ b/scripts/installtests/agent_legacy_teardown.py
@@ -17,7 +17,7 @@ import unittest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(os.environ.get("PULSE_AGENT_LEGACY_SOURCE", ROOT / "scripts/install.sh"))
-FUNCTIONS = ("read_legacy_agent_removal_processes", "legacy_agent_processes_absent",
+FUNCTIONS = ("truenas_scale_agent_removal_present", "read_legacy_agent_removal_processes", "legacy_agent_processes_absent",
"validate_legacy_agent_removal_pidfiles", "read_legacy_agent_removal_status",
"read_freebsd_agent_removal_enablement", "quiesce_freebsd_agent_for_removal",
"teardown_freebsd_agent_service", "read_sysv_agent_removal_links",
diff --git a/scripts/installtests/agent_openrc_teardown.py b/scripts/installtests/agent_openrc_teardown.py
index a2cc349ea5..fbc2102ebd 100644
--- a/scripts/installtests/agent_openrc_teardown.py
+++ b/scripts/installtests/agent_openrc_teardown.py
@@ -21,7 +21,7 @@ import unittest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(os.environ.get("PULSE_AGENT_OPENRC_SOURCE", ROOT / "scripts/install.sh"))
-FUNCTIONS = ("read_openrc_agent_removal_status", "read_openrc_agent_removal_runlevels",
+FUNCTIONS = ("truenas_scale_agent_removal_present", "read_openrc_agent_removal_status", "read_openrc_agent_removal_runlevels",
"quiesce_openrc_agent_for_removal", "teardown_openrc_agent_service")
diff --git a/scripts/installtests/agent_truenas_teardown.py b/scripts/installtests/agent_truenas_teardown.py
new file mode 100644
index 0000000000..624ed9293a
--- /dev/null
+++ b/scripts/installtests/agent_truenas_teardown.py
@@ -0,0 +1,387 @@
+#!/usr/bin/env python3
+"""TrueNAS SCALE uninstall, using production functions and private manager models.
+
+Only fixed vendor/service paths are redirected into owned temporary files.
+No appliance, root installer, network, credential or native acceptance is used.
+The complete uninstall caller runs; middleware/process/systemd are closed models.
+"""
+import json
+import os
+from pathlib import Path
+import shlex
+import stat
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[2]
+SOURCE = Path(os.environ.get("PULSE_AGENT_TRUENAS_SOURCE", ROOT / "scripts/install.sh"))
+FUNCTIONS = (
+ "portable_path_uid", "portable_path_mode", "trusted_lifecycle_regular_file",
+ "read_agent_removal_unit_state", "confirm_agent_unit_quiescent",
+ "quiesce_agent_units_for_removal", "teardown_systemd_agent_service",
+ "read_legacy_agent_removal_processes", "legacy_agent_processes_absent",
+ "truenas_scale_agent_removal_present", "read_truenas_agent_removal_tasks",
+ "quiesce_truenas_agent_boot_tasks_for_removal", "confirm_truenas_agent_removal_quiescent",
+ "teardown_truenas_agent_boot_tasks",
+)
+
+
+def definitions(source):
+ selected = []
+ for name in FUNCTIONS:
+ marker = name + "() {"
+ if marker not in source: # The exact-parent adverse comparison.
+ continue
+ if source.count(marker) != 1:
+ raise ValueError("ambiguous production function " + name)
+ selected.append(marker + source.split(marker, 1)[1].split("\n}\n", 1)[0] + "\n}\n")
+ return "\n".join(selected)
+
+
+MODEL = r'''#!/usr/bin/python3
+import json, os, pathlib, sys
+root = pathlib.Path(os.environ['FIXTURE'])
+path = root / 'model.json'
+v = json.loads(path.read_text())
+role, args = pathlib.Path(sys.argv[0]).name, sys.argv[1:]
+fault = v['fault']
+def save():
+ pending = root / 'model.pending'
+ pending.write_text(json.dumps(v)); pending.replace(path)
+def event(s):
+ with (root / 'events').open('a') as f: f.write(s + '\n')
+remote = (root / 'events').exists() and 'remote:remove' in (root / 'events').read_text()
+if role == 'ps':
+ assert args == ['-ax', '-o', 'pid=', '-o', 'ppid=', '-o', 'comm=', '-o', 'args='], args
+ event('process:read')
+ if fault == 'failed-process': sys.exit(1)
+ if fault == 'empty-process': sys.exit(0)
+ print('1 0 init /sbin/init')
+ if fault == 'duplicate-process': print('1 0 init /sbin/init')
+ if v['active'] == 'active' or fault == 'orphan-agent':
+ print('100 1 pulse-agent /usr/local/bin/pulse-agent --token-file /synthetic/recovery-only')
+ if fault == 'live-bootstrap': print('101 1 bash /bin/bash ' + str(root / 'state/bootstrap-pulse-agent.sh'))
+ if fault == 'diagnostic-process': print('read unavailable', file=sys.stderr)
+ sys.exit(0)
+if role == 'systemctl':
+ verb = args[0]
+ if verb == 'daemon-reload': event('unit:reload'); sys.exit(0)
+ assert len(args) >= 2, args
+ unit = args[1]
+ assert unit in ('pulse-agent.service', 'pulse-agent-helper.service', 'pulse-agent-helper.socket', 'pulse-agent-runner.service'), args
+ if verb == 'show':
+ assert args[2:] == ['--property=LoadState,ActiveState,UnitFileState'], args
+ event('unit:read:' + unit)
+ if fault == 'failed-unit-read': sys.exit(1)
+ present = unit == 'pulse-agent.service' and (root / 'unit/pulse-agent.service').exists()
+ if present:
+ print('LoadState=loaded\nActiveState=' + v['active'] + '\nUnitFileState=' + v['enabled'])
+ else: print('LoadState=not-found\nActiveState=inactive\nUnitFileState=')
+ sys.exit(0)
+ assert verb in ('stop', 'disable'), args
+ event('unit:' + verb)
+ if fault == 'refused-stop' and verb == 'stop': sys.exit(1)
+ if verb == 'stop' and fault != 'ignored-stop': v['active'] = 'inactive'
+ if verb == 'stop' and fault == 'reactivated-task-on-stop': v['tasks'][0]['enabled'] = True
+ if verb == 'disable': v['enabled'] = 'disabled'
+ save(); sys.exit(0)
+assert role == 'midclt' and args[0] == 'call', (role, args)
+verb = args[1]
+if verb == 'initshutdownscript.query':
+ assert len(args) in (2, 3), args
+ event('task:query'); v['queries'] += 1
+ if fault == 'timeout-query':
+ save(); (root / 'timed-pid').write_text(str(os.getpid()))
+ os.execl('/bin/sleep', 'sleep', '30')
+ if fault == 'failed-query' or (fault == 'failed-after-remote-query' and remote): save(); sys.exit(1)
+ if fault == 'new-task-after-remote' and remote and not any(t['id'] == 3 for t in v['tasks']):
+ v['tasks'].append(dict(v['tasks'][0], id=3, enabled=True))
+ if fault == 'reactivated-task' and v['updates'] and v['tasks']: v['tasks'][0]['enabled'] = True
+ if fault == 'recreated-task' and v['deletes'] and not any(t['script'] == str(root / 'state/bootstrap-pulse-agent.sh') for t in v['tasks']):
+ v['tasks'].append(dict(id=3, type='SCRIPT', script=str(root / 'state/bootstrap-pulse-agent.sh'), command='', when='POSTINIT', enabled=True, timeout=30, comment='Pulse Agent Bootstrap'))
+ save()
+ tasks = v['tasks']
+ if len(args) == 3: # Preserve the parent's exact filter, not a new graph.
+ filters = json.loads(args[2]); assert filters[0][:2] == ['script', '='], filters
+ tasks = [t for t in tasks if t['script'] == filters[0][2]]
+ if fault == 'empty-query': sys.exit(0)
+ if fault == 'malformed-query': print('not-json'); sys.exit(0)
+ if fault == 'object-query': print('{}'); sys.exit(0)
+ if fault == 'duplicate-field': print('[{"id":1,"id":2}]'); sys.exit(0)
+ if fault == 'oversized-query': print(' ' * 1048577 + '[]'); sys.exit(0)
+ if fault == 'oversized-inventory': tasks = [dict(v['tasks'][0], id=i + 1) for i in range(513)]
+ if fault == 'duplicate-id': tasks = tasks + [tasks[0]]
+ if fault == 'bool-id': tasks[0]['id'] = True
+ if fault == 'string-enabled': tasks[0]['enabled'] = 'false'
+ if fault == 'foreign-owner': tasks[0]['comment'] = 'not owned by Pulse'
+ if fault == 'wrong-kind': tasks[0]['type'] = 'UNKNOWN'
+ if fault == 'missing-script': tasks[0].pop('script')
+ if fault == 'null-script': tasks[0]['script'] = None
+ if fault == 'empty-command': tasks[0].update(type='COMMAND', script='', command='')
+ if fault == 'command-bootstrap': tasks[0].update(type='COMMAND', script='', command='/bin/bash ' + str(root / 'state/bootstrap-pulse-agent.sh'))
+ if fault == 'other-bootstrap': tasks[0]['script'] = '/other-install/bootstrap-pulse-agent.sh'
+ if fault == 'diagnostic-query': print('read unavailable', file=sys.stderr)
+ print(json.dumps(tasks)); sys.exit(0)
+if verb == 'initshutdownscript.update':
+ assert len(args) == 4 and json.loads(args[3]) == {'enabled': False}, args
+ ident = int(args[2]); event('task:disable:' + str(ident)); v['updates'] += 1
+ if fault == 'refused-disable' or (fault == 'partial-disable' and ident == 2): save(); sys.exit(1)
+ task = next(t for t in v['tasks'] if t['id'] == ident)
+ if fault != 'ignored-disable': task['enabled'] = False
+ save(); print(json.dumps(task)); sys.exit(0)
+assert verb == 'initshutdownscript.delete' and len(args) == 3, args
+ident = int(args[2]); event('task:delete:' + str(ident)); v['deletes'] += 1
+if fault == 'refused-delete' or (fault == 'partial-delete' and ident == 2): save(); sys.exit(1)
+if fault != 'ignored-delete': v['tasks'] = [t for t in v['tasks'] if t['id'] != ident]
+if fault == 'unit-reactivation-on-delete': v['active'] = 'active'
+save(); print('true')
+'''
+
+
+class AgentTrueNASTeardown(unittest.TestCase):
+ source = SOURCE.read_text()
+
+ def exercise(self, fault="", reply="confirmed", bootstrap="regular", tasks="duplicate",
+ appliance=True, repeat=False, flow="caller"):
+ with tempfile.TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ tools = root / "tools"; tools.mkdir()
+ for name in ("midclt", "ps", "systemctl"):
+ file = tools / name; file.write_text(MODEL); file.chmod(0o700)
+ if fault == "missing-midclt": (tools / "midclt").unlink()
+ for name in ("state", "helper", "profile", "unit"):
+ directory = root / name; directory.mkdir(mode=0o700)
+ if name != "unit":
+ for file in ("runtime.token", "agent-id", "connection.env", "install.sh"):
+ (directory / file).write_text("synthetic recovery only\n")
+ script = root / "state/bootstrap-pulse-agent.sh"
+ unrelated = root / "unrelated"; unrelated.write_text("do not alter\n")
+ if bootstrap == "linked": script.symlink_to(unrelated)
+ elif bootstrap == "directory": script.mkdir()
+ elif bootstrap != "missing":
+ script.write_text("#!/bin/bash\n# Pulse Agent Bootstrap for TrueNAS\n")
+ script.chmod(0o777 if bootstrap == "writable" else 0o755)
+ vendor = root / "truenas-version"
+ if appliance: vendor.write_text("TrueNAS-SCALE\n")
+ (root / "version").write_text("unrelated platform\n")
+ (root / "unit/pulse-agent.service").write_text("owned service\n")
+ owned = dict(id=1, type="SCRIPT", script=str(script), command="", when="POSTINIT",
+ enabled=True, timeout=30, comment="Pulse Agent Bootstrap")
+ foreign = dict(owned, id=99, script="/unrelated/boot.sh", comment="unrelated task")
+ selected = [] if tasks == "absent" else [owned]
+ if tasks == "duplicate": selected.append(dict(owned, id=2))
+ if tasks == "disabled": selected[0]["enabled"] = False
+ selected.append(foreign)
+ model = dict(fault=fault, tasks=selected, updates=0, deletes=0, queries=0,
+ active="active", enabled="enabled")
+ (root / "model.json").write_text(json.dumps(model))
+ before = self.snapshot(root)
+
+ def redirect(source):
+ for path, target in (
+ ("/etc/truenas-version", vendor), ("/etc/version", root / "version"),
+ ("/data/ix-applications", root / "no-ix"), ("/etc/ix-apps.d", root / "no-apps"),
+ ("/etc/ix.rc.d", root / "no-rc"), ("/usr/syno", root / "no-dsm"),
+ ("/etc/unraid-version", root / "no-unraid"),
+ ("/boot/config/plugins/pulse-agent", root / "no-plugin"),
+ ("/etc/config/qpkg.conf", root / "no-qnap"),
+ ("/sbin/getcfg", root / "no-getcfg"),
+ ("/var/log/pulse-agent.log", root / "absent-log"),
+ ("/etc/systemd/system/${AGENT_NAME}.service", root / "unit/pulse-agent.service"),
+ ): source = source.replace(path, str(target))
+ return source
+
+ values = dict(FIXTURE=root, EVENTS=root / "events", STATE_DIR=root / "state",
+ INSTALL_DIR=root / "bin",
+ STATE_DIR_REMOVAL_AUTHORITY=root / "state",
+ TRUENAS_STATE_DIR=root / "state", TRUENAS_BOOTSTRAP_SCRIPT=script,
+ PRIVILEGED_HELPER_STATE_DIR=root / "helper", SAFE_PROFILE_STATE_DIR=root / "profile",
+ PRIVILEGE_SUDOERS_FILE=root / "absent-sudoers", PRIVILEGE_HELPER_DIR=root / "absent-helper",
+ REPLY=reply, FAULT=fault)
+ setup = "\n".join(k + "=" + shlex.quote(str(v)) for k, v in values.items())
+ shell = r'''
+set -euo pipefail
+UNINSTALL=true AGENT_NAME=pulse-agent BINARY_NAME=pulse-agent INSTALL_DIR=/usr/local/bin
+EXIT_GENERAL=1 EXIT_MISSING_ARGS=16 PULSE_URL=https://same-instance.example.invalid AGENT_ID=synthetic
+ACTION_RUNNER_NAME=pulse-agent-runner PRIVILEGED_HELPER_NAME=pulse-agent-helper
+log_info() { :; }; log_warn() { :; }
+fail() { printf '%s\n' "$1" >&2; exit "$2"; }
+uname() { printf 'Linux\n'; }
+command() {
+ case "$*" in
+ '-v rc-service') return 1 ;;
+ '-v midclt') [[ "$FAULT" != missing-midclt ]] || return 1 ;;
+ '-v python3') [[ "$FAULT" != missing-python ]] || return 1 ;;
+ '-v timeout') [[ "$FAULT" != missing-timeout ]] || return 1 ;;
+ esac
+ builtin command "$@"
+}
+collector_credential_state_present() { [[ -e "$STATE_DIR/runtime.token" ]]; }
+uninstall_collector_registration() { printf 'remote:remove\n' >> "$EVENTS"; [[ "$REPLY" == confirmed ]]; }
+teardown_action_runner_service() { :; }
+teardown_privileged_helper_service() { :; }
+find_qnap_state_dir() { :; }
+pkill() { printf 'unchecked:process-stop\n' >> "$EVENTS"; }
+sleep() { :; }
+rm() {
+ local arg
+ for arg in "$@"; do [[ "$arg" == -* || "$arg" == "$FIXTURE/"* ]] || return 97; done
+ printf 'file:remove\n' >> "$EVENTS"
+ builtin command rm "$@"
+}
+remove_agent_state_dir() { printf 'state:remove\n' >> "$EVENTS"; rm -rf -- "$STATE_DIR"; }
+remove_privileged_helper_state_dir() { rm -rf -- "$PRIVILEGED_HELPER_STATE_DIR"; }
+remove_safe_profile_state_dir() { rm -rf -- "$SAFE_PROFILE_STATE_DIR"; }
+'''
+ entry = self.source.split("# --- Uninstall Logic ---\n", 1)[1].split("# --- Validation ---\n", 1)[0]
+ selected = definitions(self.source)
+ if flow == "process": entry = 'read_legacy_agent_removal_processes "$TRUENAS_BOOTSTRAP_SCRIPT"\n'
+ executable = shell + "\n" + setup + "\n" + redirect(selected) + "\nmain() {\n" + redirect(entry) + "}\nmain\n"
+ env = dict(os.environ, FIXTURE=str(root), PATH=str(tools) + ":" + os.environ["PATH"])
+ result = subprocess.run(["bash", "-c", executable], env=env, capture_output=True, text=True, timeout=12)
+ second = subprocess.run(["bash", "-c", executable], env=env, capture_output=True, text=True, timeout=12) if repeat else None
+ events = (root / "events").read_text() if (root / "events").exists() else ""
+ after = self.snapshot(root)
+ state = json.loads((root / "model.json").read_text())
+ timed_stopped = not (root / "timed-pid").exists() or not Path('/proc/' + (root / "timed-pid").read_text()).exists()
+ return result, second, events.splitlines(), before, after, state, timed_stopped
+
+ @staticmethod
+ def snapshot(root):
+ result = {}
+ for path in root.rglob("*"):
+ if path.parts[len(root.parts)] not in ("state", "helper", "profile", "unit", "unrelated"):
+ continue
+ mode = path.lstat().st_mode
+ contents = os.readlink(path) if stat.S_ISLNK(mode) else path.read_bytes() if stat.S_ISREG(mode) else None
+ result[str(path.relative_to(root))] = (mode, contents, path.lstat().st_ino)
+ return result
+
+ def retained(self, result):
+ execution, _, events, before, after, _, _ = result
+ self.assertEqual(execution.returncode, 1, execution.stdout + execution.stderr)
+ self.assertNotIn('state:remove', events)
+ for path in before:
+ if path.startswith(('state', 'helper', 'profile')) or path == 'unrelated': self.assertEqual(after.get(path), before[path], path)
+ self.assertNotIn('synthetic recovery only', execution.stdout + execution.stderr)
+ return events
+
+ def test_unavailable_or_untrusted_task_inventory_cannot_remove_recovery(self):
+ for fault in ('failed-query', 'empty-query', 'malformed-query', 'object-query', 'duplicate-field',
+ 'diagnostic-query', 'duplicate-id', 'bool-id', 'string-enabled', 'wrong-kind',
+ 'oversized-query', 'oversized-inventory'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertNotIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:disable:') or e.startswith('task:delete:') for e in events))
+
+ def test_foreign_or_indirect_bootstrap_is_not_mutated(self):
+ for fault in ('foreign-owner', 'command-bootstrap', 'other-bootstrap'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertNotIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:disable:') or e.startswith('task:delete:') for e in events))
+
+ def test_missing_activation_target_cannot_hide_an_owned_task(self):
+ for fault in ('missing-script', 'null-script', 'empty-command'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertNotIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:disable:') or e.startswith('task:delete:') for e in events))
+
+ def test_bootstrap_symlink_directory_or_writable_file_refuses_before_mutation(self):
+ for bootstrap in ('linked', 'directory', 'writable'):
+ with self.subTest(bootstrap=bootstrap):
+ events = self.retained(self.exercise(bootstrap=bootstrap))
+ self.assertEqual(events, [])
+
+ def test_missing_tools_are_not_local_only_removal_permission(self):
+ for fault in ('missing-midclt', 'missing-python', 'missing-timeout'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertEqual(events, [])
+
+ def test_every_matching_task_is_disabled_and_independently_observed_before_stop(self):
+ result = self.exercise()
+ self.assertEqual(result[0].returncode, 0, result[0].stderr)
+ events = result[2]
+ self.assertLess(events.index('task:disable:2'), events.index('unit:stop'))
+ self.assertEqual(events[:4], ['task:query', 'task:disable:1', 'task:disable:2', 'task:query'])
+ self.assertLess(events.index('process:read'), events.index('remote:remove'))
+
+ def test_partial_refused_ignored_or_reactivated_disable_retains_recovery(self):
+ for fault in ('refused-disable', 'partial-disable', 'ignored-disable', 'reactivated-task'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertNotIn('remote:remove', events)
+ self.assertNotIn('unit:stop', events)
+
+ def test_unit_or_process_uncertainty_keeps_stopped_task_recovery(self):
+ for fault in ('failed-unit-read', 'refused-stop', 'ignored-stop', 'failed-process', 'empty-process',
+ 'duplicate-process', 'diagnostic-process', 'orphan-agent', 'live-bootstrap'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertNotIn('remote:remove', events)
+
+ def test_task_activation_during_systemd_stop_refuses_before_remote(self):
+ result = self.exercise('reactivated-task-on-stop')
+ events = self.retained(result)
+ self.assertIn('unit:stop', events)
+ self.assertNotIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:delete:') for e in events))
+
+ def test_server_refusal_retains_disabled_tasks_and_all_recovery_without_restart(self):
+ result = self.exercise(reply='refused')
+ events = self.retained(result)
+ self.assertIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:delete:') for e in events))
+ self.assertTrue(all(not t['enabled'] for t in result[5]['tasks'] if t['id'] != 99))
+
+ def test_changed_or_refused_post_remote_inventory_precedes_first_deletion(self):
+ for fault in ('failed-after-remote-query', 'new-task-after-remote'):
+ with self.subTest(fault=fault):
+ events = self.retained(self.exercise(fault))
+ self.assertIn('remote:remove', events)
+ self.assertFalse(any(e.startswith('task:delete:') for e in events))
+
+ def test_partial_refused_ignored_or_recreated_task_deletion_keeps_recovery(self):
+ for fault in ('refused-delete', 'partial-delete', 'ignored-delete', 'recreated-task', 'unit-reactivation-on-delete'):
+ with self.subTest(fault=fault): self.retained(self.exercise(fault))
+
+ def test_all_owned_tasks_retire_before_state_erasure_and_unrelated_task_survives(self):
+ result = self.exercise()
+ self.assertEqual(result[0].returncode, 0, result[0].stderr)
+ events = result[2]
+ self.assertLess(events.index('task:delete:2'), events.index('state:remove'))
+ self.assertIn('task:query', events[events.index('task:delete:2') + 1:events.index('state:remove')])
+ self.assertEqual([t['id'] for t in result[5]['tasks']], [99])
+ self.assertTrue(result[5]['tasks'][0]['enabled'])
+ self.assertNotIn('state/runtime.token', result[4])
+ self.assertEqual(result[4]['unrelated'], result[3]['unrelated'])
+
+ def test_disabled_absent_missing_bootstrap_and_repeated_removal_are_observed(self):
+ for tasks, bootstrap in (('disabled', 'regular'), ('absent', 'missing'), ('single', 'missing')):
+ with self.subTest(tasks=tasks, bootstrap=bootstrap):
+ result = self.exercise(tasks=tasks, bootstrap=bootstrap, repeat=True)
+ self.assertEqual(result[0].returncode, 0, result[0].stderr)
+ self.assertEqual(result[1].returncode, 0, result[1].stderr)
+ self.assertEqual([t['id'] for t in result[5]['tasks']], [99])
+
+ def test_ordinary_linux_state_is_not_appliance_evidence(self):
+ result = self.exercise(bootstrap='missing', tasks='absent', appliance=False)
+ self.assertEqual(result[0].returncode, 0, result[0].stderr)
+ self.assertFalse(any(e.startswith('task:') for e in result[2]))
+
+ def test_process_read_exposes_only_roles_and_real_timeout_child_is_stopped(self):
+ result = self.exercise(fault='live-bootstrap', flow='process')
+ self.assertEqual(result[0].returncode, 0, result[0].stderr)
+ self.assertIn('101 1 bootstrap', result[0].stdout)
+ self.assertNotIn('recovery-only', result[0].stdout + result[0].stderr)
+ result = self.exercise(fault='timeout-query')
+ self.retained(result)
+ self.assertTrue(result[6], 'timed middleware child still exists')
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=2)
diff --git a/scripts/installtests/agent_uninstall_state.py b/scripts/installtests/agent_uninstall_state.py
index 029e6a8fe0..725ac2b4c4 100644
--- a/scripts/installtests/agent_uninstall_state.py
+++ b/scripts/installtests/agent_uninstall_state.py
@@ -48,7 +48,7 @@ class UnixUninstallState(unittest.TestCase):
# Stop before unrelated platform boot/log cleanup; never run it on a host.
entry = source.split("# --- Uninstall Logic ---\n", 1)[1].split(
" # Remove least-privilege helper artifacts.", 1)[0] + "fi\n"
- names = ("collector_credential_state_present", "uninstall_collector_registration",
+ names = ("truenas_scale_agent_removal_present", "collector_credential_state_present", "uninstall_collector_registration",
"remove_authorized_runtime_dir", "remove_agent_state_dir",
"remove_privileged_helper_state_dir", "remove_safe_profile_state_dir", "fail")
with tempfile.TemporaryDirectory() as temporary:
diff --git a/scripts/installtests/agent_upstart_teardown.py b/scripts/installtests/agent_upstart_teardown.py
index 376a405435..3b4a6a5d4b 100644
--- a/scripts/installtests/agent_upstart_teardown.py
+++ b/scripts/installtests/agent_upstart_teardown.py
@@ -16,7 +16,7 @@ import unittest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(os.environ.get("PULSE_AGENT_UPSTART_SOURCE", ROOT / "scripts/install.sh"))
-FUNCTIONS = ("cleanup", "portable_path_uid", "portable_path_mode", "trusted_lifecycle_regular_file",
+FUNCTIONS = ("truenas_scale_agent_removal_present", "cleanup", "portable_path_uid", "portable_path_mode", "trusted_lifecycle_regular_file",
"read_legacy_agent_removal_processes", "legacy_agent_processes_absent",
"read_upstart_agent_removal_status", "upstart_agent_removal_paths_safe",
"read_upstart_agent_removal_enablement", "quiesce_upstart_agent_for_removal",
diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go
index 490b04be73..635f494f41 100644
--- a/scripts/installtests/install_sh_test.go
+++ b/scripts/installtests/install_sh_test.go
@@ -8501,3 +8501,42 @@ func TestInstallSHUpstartTeardownRequiresObservedQuiescence(t *testing.T) {
}
t.Logf("%s", output)
}
+
+func TestInstallSHTrueNASScaleBootTasksRetireBeforeRecoveryErasure(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("TrueNAS SCALE shell controls; Windows has its own native SCM gate")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "installtests", "agent_truenas_teardown.py"))
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("TrueNAS SCALE boot-task ownership/retirement controls: %v\n%s", err, output)
+ }
+ for _, name := range []string{
+ "test_unavailable_or_untrusted_task_inventory_cannot_remove_recovery",
+ "test_foreign_or_indirect_bootstrap_is_not_mutated",
+ "test_missing_activation_target_cannot_hide_an_owned_task",
+ "test_bootstrap_symlink_directory_or_writable_file_refuses_before_mutation",
+ "test_missing_tools_are_not_local_only_removal_permission",
+ "test_every_matching_task_is_disabled_and_independently_observed_before_stop",
+ "test_partial_refused_ignored_or_reactivated_disable_retains_recovery",
+ "test_unit_or_process_uncertainty_keeps_stopped_task_recovery",
+ "test_task_activation_during_systemd_stop_refuses_before_remote",
+ "test_server_refusal_retains_disabled_tasks_and_all_recovery_without_restart",
+ "test_changed_or_refused_post_remote_inventory_precedes_first_deletion",
+ "test_partial_refused_ignored_or_recreated_task_deletion_keeps_recovery",
+ "test_all_owned_tasks_retire_before_state_erasure_and_unrelated_task_survives",
+ "test_disabled_absent_missing_bootstrap_and_repeated_removal_are_observed",
+ "test_ordinary_linux_state_is_not_appliance_evidence",
+ "test_process_read_exposes_only_roles_and_real_timeout_child_is_stopped",
+ } {
+ if !strings.Contains(string(output), name+" (__main__.AgentTrueNASTeardown."+name+") ... ok") {
+ t.Fatalf("TrueNAS SCALE control %s did not pass:\n%s", name, output)
+ }
+ }
+ if !strings.Contains(string(output), "\nRan 16 tests in ") || !strings.Contains(string(output), "\nOK\n") {
+ t.Fatalf("incomplete TrueNAS SCALE verdict:\n%s", output)
+ }
+ t.Logf("%s", output)
+}