Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5a854be
docs(updater): PR 2 (Tier 2 manual-click) implementation plan
JohnMcLear May 8, 2026
7fd3f3a
feat(updater): extend state + settings for Tier 2 manual-click
JohnMcLear May 8, 2026
a575664
feat(updater): PID-based update.lock with stale-pid reaping
JohnMcLear May 8, 2026
04185f1
feat(updater): verifyReleaseTag — gpg-via-git stub for Tier 2 preflight
JohnMcLear May 8, 2026
658e85d
feat(updater): preflight check pipeline for Tier 2
JohnMcLear May 8, 2026
90b69e9
feat(updater): rolling update.log helpers (appendLine + tailLines)
JohnMcLear May 8, 2026
3f03472
feat(updater): SessionDrainer + handshake guard
JohnMcLear May 8, 2026
88a99c0
feat(updater): UpdateExecutor — snapshot, fetch/checkout/install/buil…
JohnMcLear May 8, 2026
46e68f3
feat(updater): RollbackHandler — health-check timer + crash-loop guard
JohnMcLear May 8, 2026
f4ba409
feat(updater): wire RollbackHandler into boot + UpdatePolicy honours …
JohnMcLear May 8, 2026
11dd991
feat(updater): apply / cancel / acknowledge / log endpoints
JohnMcLear May 8, 2026
719f1b4
feat(updater): admin UI Apply/Cancel/Acknowledge + live log stream
JohnMcLear May 8, 2026
dd79daf
feat(updater): pad shoutMessage renders update.drain.* via html10n
JohnMcLear May 8, 2026
db49a20
feat(updater): rollback uses git checkout -f + integration suite over…
JohnMcLear May 8, 2026
6107c1e
test(updater): Playwright admin Apply / Cancel / Acknowledge flow
JohnMcLear May 8, 2026
a28f939
feat(updater): admin banner shows rollback-failed terminal alert
JohnMcLear May 8, 2026
a43f707
docs(updater): Tier 2 admin docs + manual smoke runbook + CHANGELOG
JohnMcLear May 8, 2026
2b041cc
docs(updater): note docker-friendly update flows as follow-up work
JohnMcLear May 8, 2026
41d4a42
fix(updater): address Qodo review (1-6) + Playwright strict-mode CI fix
JohnMcLear May 8, 2026
a32256b
fix(updater): address Qodo #7 (status leak) + #8 (short-drain values)
JohnMcLear May 8, 2026
9340d27
fix(updater): address Qodo follow-up — tag injection, rollback reject…
JohnMcLear May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# Unreleased

### Notable enhancements

- **Self-update subsystem — Tier 2 (manual click).**
- Admins on a git install can click "Apply update" at `/admin/update`. Etherpad runs a 60s session drain (with T-60 / T-30 / T-10 broadcasts to every pad), `git fetch / checkout / pnpm install --frozen-lockfile / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. The next boot runs a 60s health check; if `/health` doesn't come up the previous SHA + lockfile are restored automatically.
- Crash-loop guard: if the new version reboots more than twice without the health check completing, RollbackHandler forces a rollback regardless of the timer.
- Terminal `rollback-failed` state surfaces a strong banner; the admin clicks Acknowledge once they've manually recovered to clear the lock and re-allow Tier 2 attempts.
- New settings under `updates.*`: `preApplyGraceMinutes`, `drainSeconds`, `rollbackHealthCheckSeconds`, `diskSpaceMinMB`, `requireSignature`, `trustedKeysPath`. Tag signature verification is opt-in (default `false`) — see `doc/admin/updates.md` for the keyring setup.
- **A process supervisor (systemd / pm2 / docker `--restart=unless-stopped`) is required to apply updates.** Without one, exit 75 leaves the instance down.
- Tiers 3 (auto with grace window) and 4 (autonomous in maintenance window) remain designed but unimplemented and will land in subsequent releases.

# 2.7.3

### Breaking changes
Expand Down
16 changes: 15 additions & 1 deletion admin/src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,21 @@ export const UpdateBanner = () => {
return () => { cancelled = true; };
}, [setUpdateStatus]);

if (!updateStatus || !updateStatus.latest) return null;
if (!updateStatus) return null;

// Terminal rollback-failed wins over the regular "update available" banner —
// an admin who left the system in this state needs to fix it before any
// other admin work matters.
if (updateStatus.execution?.status === 'rollback-failed') {
return (
<div className="update-banner update-banner-terminal" role="alert">
<strong><Trans i18nKey="update.banner.terminal.rollback-failed"/></strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}

if (!updateStatus.latest) return null;
if (updateStatus.currentVersion === updateStatus.latest.version) return null;

return (
Expand Down
132 changes: 111 additions & 21 deletions admin/src/pages/UpdatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,75 @@ type FetchState =
| {kind: 'error', status: number}
| {kind: 'ok'};

const IN_FLIGHT_STATUSES = ['preflight', 'draining', 'executing', 'rolling-back'];

export const UpdatePage = () => {
const {t} = useTranslation();
const us = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
const log = useStore((s) => s.updateLog);
const setLog = useStore((s) => s.setUpdateLog);
// Self-fetch so the page renders an explicit state even if UpdateBanner's
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
// if requireAdminForStatus is set, or a transient network error).
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
const [actionInFlight, setActionInFlight] = useState(false);

const refreshStatus = async () => {
try {
const r = await fetch('/admin/update/status', {credentials: 'same-origin'});
if (r.ok) {
const data = await r.json();
setUpdateStatus(data);
setFetchState({kind: 'ok'});
} else if (r.status === 404) {
setFetchState({kind: 'disabled'});
} else if (r.status === 401 || r.status === 403) {
setFetchState({kind: 'unauthorized'});
} else {
setFetchState({kind: 'error', status: r.status});
}
} catch {
setFetchState({kind: 'error', status: 0});
}
};

useEffect(() => {
let cancelled = false;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then(async (r) => {
if (cancelled) return;
if (r.ok) {
const data = await r.json();
setUpdateStatus(data);
setFetchState({kind: 'ok'});
} else if (r.status === 404) {
setFetchState({kind: 'disabled'});
} else if (r.status === 401 || r.status === 403) {
setFetchState({kind: 'unauthorized'});
} else {
setFetchState({kind: 'error', status: r.status});
}
})
.catch(() => {
if (!cancelled) setFetchState({kind: 'error', status: 0});
});
void refreshStatus().then(() => { if (cancelled) return; });
return () => { cancelled = true; };
}, [setUpdateStatus]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Poll log + status while the executor is in flight, then stop.
const status = us?.execution?.status ?? 'idle';
const inFlight = IN_FLIGHT_STATUSES.includes(status);
useEffect(() => {
if (!inFlight) return;
let cancelled = false;
const tick = async () => {
if (cancelled) return;
try {
const lr = await fetch('/admin/update/log', {credentials: 'same-origin'});
if (lr.ok) setLog(await lr.text());
} catch {/* noop */}
await refreshStatus();
if (!cancelled) setTimeout(tick, 1000);
};
void tick();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inFlight]);

const post = async (path: string) => {
setActionInFlight(true);
try {
await fetch(path, {method: 'POST', credentials: 'same-origin'});
await refreshStatus();
} finally {
setActionInFlight(false);
}
};

if (fetchState.kind === 'loading') {
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
Expand All @@ -61,16 +99,22 @@ export const UpdatePage = () => {
);
}
if (fetchState.kind === 'error' || !us) {
const status = fetchState.kind === 'error' ? fetchState.status : 0;
const stat = fetchState.kind === 'error' ? fetchState.status : 0;
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p>
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status: stat})}</p>
</div>
);
}

const upToDate = !us.latest || us.currentVersion === us.latest.version;
const showApply = !!us.policy?.canManual
&& (status === 'idle' || status === 'verified')
&& !us.lockHeld
&& !upToDate;
const showCancel = status === 'preflight' || status === 'draining';
const showAcknowledge = status === 'preflight-failed' || status === 'rolled-back' || status === 'rollback-failed';

return (
<div className="update-page">
Expand All @@ -86,7 +130,53 @@ export const UpdatePage = () => {
<dd>{us.installMethod}</dd>
<dt><Trans i18nKey="update.page.tier"/></dt>
<dd>{us.tier}</dd>
<dt><Trans i18nKey="update.page.execution"/></dt>
<dd>{t(`update.execution.${status}`, {defaultValue: status})}</dd>
</dl>

{us.lastResult && (
<p className={`last-result last-result-${us.lastResult.outcome}`}>
<Trans
i18nKey={`update.page.last_result.${us.lastResult.outcome}`}
values={{tag: us.lastResult.targetTag, reason: us.lastResult.reason ?? ''}}
/>
</p>
)}

{us.policy && !us.policy.canManual && !upToDate && (
<p className="policy-deny">
<Trans
i18nKey={`update.page.policy.${us.policy.reason}`}
defaults={us.policy.reason}
/>
</p>
)}

<div className="update-actions">
{showApply && (
<button onClick={() => post('/admin/update/apply')} disabled={actionInFlight}>
{t('update.page.apply')}
</button>
)}
{showCancel && (
<button onClick={() => post('/admin/update/cancel')} disabled={actionInFlight}>
{t('update.page.cancel')}
</button>
)}
{showAcknowledge && (
<button onClick={() => post('/admin/update/acknowledge')} disabled={actionInFlight}>
{t('update.page.acknowledge')}
</button>
)}
</div>

{inFlight && (
<section className="update-log">
<h2><Trans i18nKey="update.page.log"/></h2>
<pre style={{whiteSpace: 'pre-wrap', maxHeight: '320px', overflow: 'auto'}}>{log}</pre>
</section>
)}

{upToDate ? (
<p><Trans i18nKey="update.page.up_to_date"/></p>
) : us.latest ? (
Expand Down
28 changes: 28 additions & 0 deletions admin/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import {Socket} from "socket.io-client";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";

export type Execution =
| {status: 'idle'}
| {status: 'preflight'; targetTag: string; startedAt: string}
| {status: 'preflight-failed'; targetTag: string; reason: string; at: string}
| {status: 'draining'; targetTag: string; drainEndsAt: string; startedAt: string}
| {status: 'executing'; targetTag: string; fromSha: string; startedAt: string}
| {status: 'pending-verification'; targetTag: string; fromSha: string; deadlineAt: string}
| {status: 'verified'; targetTag: string; verifiedAt: string}
| {status: 'rolling-back'; reason: string; targetTag: string; fromSha: string; at: string}
| {status: 'rolled-back'; reason: string; targetTag: string; restoredSha: string; at: string}
| {status: 'rollback-failed'; reason: string; targetTag: string; fromSha: string; at: string};

export type LastResult = null | {
targetTag: string;
fromSha: string;
outcome: 'verified' | 'rolled-back' | 'rollback-failed' | 'preflight-failed' | 'cancelled';
reason: string | null;
at: string;
};

export interface UpdateStatusPayload {
currentVersion: string;
latest: null | {
Expand All @@ -18,6 +38,10 @@ export interface UpdateStatusPayload {
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;
lockHeld: boolean;
}

type ToastState = {
Expand Down Expand Up @@ -45,6 +69,8 @@ type StoreState = {
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
updateStatus: UpdateStatusPayload | null,
setUpdateStatus: (s: UpdateStatusPayload) => void,
updateLog: string,
setUpdateLog: (log: string) => void,
}


Expand All @@ -70,4 +96,6 @@ export const useStore = create<StoreState>()((set) => ({
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
updateStatus: null,
setUpdateStatus: (s) => set({updateStatus: s}),
updateLog: '',
setUpdateLog: (log) => set({updateLog: log}),
}));
Loading
Loading