mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-21 00:59:11 +00:00
* docs(updater): PR 2 (Tier 2 manual-click) implementation plan 20-task TDD plan for shipping the manual-click update flow on top of the Tier 1 (notify) work merged in #7601. Covers UpdateExecutor, RollbackHandler, SessionDrainer, lock + trustedKeys, four admin endpoints (apply / cancel / acknowledge / log), admin UI updates, integration tests against a tmp git repo, and a manual smoke runbook for the spec's "before each tier ships" gate. Plan deliberately scopes signature verification to an opt-in stub (updates.requireSignature: false default) to avoid blocking on a separate release-signing project. Plan: docs/superpowers/plans/2026-05-08-auto-update-pr2-manual-click.md Spec: docs/superpowers/specs/2026-04-25-auto-update-design.md Issue: ether/etherpad#7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): extend state + settings for Tier 2 manual-click Adds ExecutionStatus discriminated union, bootCount, and lastResult to UpdateState, plus the preApplyGraceMinutes/drainSeconds/diskSpaceMinMB/ requireSignature/trustedKeysPath knobs that Tier 2's executor needs. loadState backfills the new fields on Tier 1 state files so existing installs keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): PID-based update.lock with stale-pid reaping Single-flight guard for Tier 2's UpdateExecutor. Atomic O_CREAT|O_EXCL acquire; on EEXIST, sends signal 0 to the recorded PID and reaps if dead. Unparseable / partially-written lock files are treated as stale rather than fatal so a half-written lock from a SIGKILL'd parent doesn't lock the install out forever. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): verifyReleaseTag — gpg-via-git stub for Tier 2 preflight Default updates.requireSignature=false: log a warning and return ok with reason=signature-not-required. Set true to make preflight refuse a tag whose signature does not verify under the system keyring (or trustedKeysPath via GNUPGHOME). Etherpad's release process does not yet sign tags consistently; turning the check on by default would break Tier 2 for every admin and forcing a release-signing change is out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): preflight check pipeline for Tier 2 Pure orchestrator over injected probes for install-method, working tree, disk space, pnpm presence, lock state, remote tag existence and signature verification. Cheap-and-definitive checks run first; first failure short-circuits with a typed reason that the route layer will surface in the preflight-failed admin banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): rolling update.log helpers (appendLine + tailLines) Direct file-append + size-based rotation rather than a log4js appender — avoids re-configuring log4js on top of the user's existing logconfig. appendLine creates parents, rotates at 10MB (configurable), keeps 5 backups by default. tailLines reads the last N lines for /admin/update/log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): SessionDrainer + handshake guard Drainer schedules T-60 / -30 / -10 broadcasts and resolves at T=0; isAcceptingConnections() flips off for the duration. PadMessageHandler consults the flag at the start of CLIENT_READY and disconnects new joiners with reason "updateInProgress" — existing sockets are unaffected. Drains shorter than 30s collapse the early timers to fire ASAP rather than queue past the drain end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): UpdateExecutor — snapshot, fetch/checkout/install/build, exit 75 Pure-DI orchestrator: spawnFn, copyFile, readSha, saveState, exit are all injected so unit tests run the full pipeline without spawning real children or mutating the real install. Streams stdout/stderr to update.log via the now-best-effort appendLine helper (swallows fs errors so the executor itself never breaks on read-only / unwritable log dirs). Failure paths transition to rolling-back and return — the route layer hands off to RollbackHandler which owns the rollback exit, so we don't double-exit and lose tail lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): RollbackHandler — health-check timer + crash-loop guard checkPendingVerification arms a 60s timer at boot when state is pending-verification and increments bootCount; bootCount>2 forces an immediate rollback (crash-loop guard). markVerified persists the verified state and stops the timer. performRollback restores the backup lockfile, runs git checkout <fromSha> and pnpm install, lands on rolled-back or rollback-failed (terminal) on sub-step failure, exits 75 either way so the supervisor restart brings the new state up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): wire RollbackHandler into boot + UpdatePolicy honours rollback-failed - expressCreateServer now invokes checkPendingVerification before polling starts so a previous boot's pending-verification either re-arms the health-check timer or, when bootCount has climbed past the crash-loop threshold, forces an immediate rollback. - server.ts calls markBootHealthy after state hits RUNNING so /health-being-up is the implicit happy-path signal that cancels the rollback timer. - /admin/update/status surfaces execution + lastResult + lockHeld so the admin UI can render the right Apply / Cancel / Acknowledge state. - UpdatePolicy gains an `executionStatus` input. While it equals 'rollback-failed', canAuto / canAutonomous are denied (reason: rollback-failed-terminal); manual stays on because clicking Apply IS the intervention the terminal state needs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): apply / cancel / acknowledge / log endpoints Strict admin-only POSTs that drive Tier 2's manual-click flow: - POST /admin/update/apply: acquire lock, persist preflight, run preflight, drain $drainSeconds, executeUpdate (which exits 75 on success), or run performRollback on a failure path (also exits 75). - POST /admin/update/cancel: cancel a pre-execute drain/preflight, write cancelled lastResult, release lock. - POST /admin/update/acknowledge: clear terminal states (preflight-failed, rolled-back, rollback-failed) back to idle. lastResult is preserved so the admin still sees what happened. - GET /admin/update/log: tail var/log/update.log (200 lines) for the in- progress UI. Strict admin auth. Also: - socketio hook exports getIo() so the apply endpoint can broadcast the drain shoutMessage outside the regular hook surface. - ep.json registers updateActions after admin/updateStatus. - 11 mocha integration tests cover auth, policy denial, execution-busy, acknowledge-clears-terminal, log content-type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): admin UI Apply/Cancel/Acknowledge + live log stream UpdatePage renders the right action set based on execution.status: Apply when idle/verified and policy allows, Cancel during preflight/draining, Acknowledge on terminal preflight-failed / rolled-back / rollback-failed. While the executor is in flight (preflight/draining/executing/rolling-back) the page polls /admin/update/log + /admin/update/status once a second and shows the rolling tail; polling stops automatically when the run terminates. lastResult and policy denial reasons surface localised copy. Buttons disable themselves while a network round-trip is in flight to dodge double-clicks. New i18n keys live under update.page.{apply,cancel, acknowledge,log,execution,policy.*,last_result.*}, update.execution.*, update.banner.terminal.rollback-failed, and update.drain.{t60,t30,t10}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): pad shoutMessage renders update.drain.* via html10n broadcastShout now sends {messageKey, values, sticky} so the existing pad-side shout pipeline can route through html10n.get(). The renderer gains a values pass-through so update.drain.t60 etc. interpolate {{seconds}}, and gives updater shouts a different gritter title (the banner.title localised string) so users know it's a system event rather than a generic admin message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): rollback uses git checkout -f + integration suite over tmp git repo RollbackHandler now does git checkout -f <fromSha> BEFORE overlaying the backup lockfile. Without -f, git refuses checkout when there are unstaged modifications to files it would overwrite — exactly the case after a partial executor run that mutated the working tree. With -f the partial mutation is discarded and the working tree returns to fromSha cleanly. The backup-lockfile copy is still done (belt-and-braces) but tolerates ENOENT since checkout already restored the right lockfile. The new integration suite at src/tests/backend/specs/updater-integration.ts exercises the full pipeline against a disposable git repo: happy path, install-fail rollback, build-fail rollback, crash-loop guard, and a target-sha-doesn't-exist rollback-failed terminal case. 5 mocha tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(updater): Playwright admin Apply / Cancel / Acknowledge flow Stubs /admin/update/status (and /admin/update/apply for the apply path) at the route level so we can assert UI transitions without actually running an update. Four scenarios: - Apply button POSTs and re-fetches status (>=2 status fetches total). - install-method-not-writable hides the button and shows localised denial copy. - rollback-failed terminal state shows the Acknowledge button and the "Manual intervention required" lastResult copy. - lockHeld=true hides Apply even when policy.canManual is on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): admin banner shows rollback-failed terminal alert When execution.status === 'rollback-failed' the banner switches to a role=alert with the strong update.banner.terminal.rollback-failed copy and overrides the regular "update available" framing — an admin who left the system in this state needs to fix it before any other admin work matters. Other terminal states (preflight-failed, rolled-back) are informational and surface on the page itself, not the banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(updater): Tier 2 admin docs + manual smoke runbook + CHANGELOG doc/admin/updates.md gains a full Tier 2 section: prerequisites (git install + process supervisor with sample systemd unit), Apply flow with timings, every failure mode and the resulting state, the four endpoints, and the signature-verification opt-in. Settings table picks up the new updates.* knobs. docs/superpowers/specs/2026-04-25-auto-update-runbook.md is the manual smoke runbook the design spec calls for: disposable VM, systemd unit, every observable transition (happy path, install/ build-fail rollback, crash-loop guard, rollback-failed terminal, cancel during drain) plus a sign-off checklist for the release cut. CHANGELOG Unreleased section explains the supervisor requirement and points readers at the runbook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(updater): note docker-friendly update flows as follow-up work Tier 2 refuses Apply on installMethod=docker because in-container mutation doesn't survive a container restart. Adds a future-work note covering the two reasonable paths for an in-product docker Apply button (instructions-only vs deploy-webhook) and explicitly rules out mounting /var/run/docker.sock as a footgun. Watchtower gets a pointer for admins who want fully autonomous docker updates today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(updater): address Qodo review (1-6) + Playwright strict-mode CI fix 1. Tier 2 endpoints now gate on tier in {manual, auto, autonomous} — notify and off return 404 to match the prior PR-1 behaviour. Gate is evaluated per-request via app.use middleware so a settings.json reload takes effect without a full restart, and so integration tests can flip the tier dynamically. Adds a regression test that exercises 404 at tier=notify across all four endpoints. 2. cancel/apply race fixed: /admin/update/cancel no longer releases the lock — apply's finally block owns it for the request's lifetime. Apply now reloads state after preflight and aborts with 409 cancelled-during- preflight if execution.status is no longer 'preflight' for the same targetTag. Prevents a second apply from sneaking in while the first is still running its slow checks, and prevents the post-cancel apply from continuing into drain/execute. 3. SessionDrainer now restores acceptingConnections=true at drain completion (not just on cancel). The lock + persisted execution.status prevent a fresh apply from racing in — the in-memory flag was redundant safety that turned into a wedge if the executor threw post-drain. Adds a unit test asserting the flag is restored after natural drain end. 4. PadMessageHandler drain guard switched from socket.json.send (a socket.io v2/v3 API that may not exist on v4) to socket.emit('message', ...) for consistency with the other disconnect paths in the file. 5. Spawn 'error' handlers added to runStep helpers in UpdateExecutor and RollbackHandler, plus the gpg verify-tag spawn in trustedKeys. Without them, a missing/unexecutable binary leaves the promise hanging forever and the update flow stuck in-flight. SpawnFn type extended to allow on('error', ...) listeners cleanly. Spawn errors now resolve with code 1 + the error message in stderr, so the existing failure-detection branches fire normally. 6. executeUpdate body wrapped in try/catch. An exception from readSha, saveState, copyFile, or any step now lands in a rolling-back persist + returns failed-checkout, so the route's post-executor rollback path picks it up. State can no longer wedge at 'executing'. The catch's inner saveState is itself try/wrapped so a write-after-write failure doesn't crash the route either. CI: Playwright update-page-actions strict-mode violation fixed. Both the banner and the lastResult <p> contain "Manual intervention required"; selector now scopes to p.last-result-rollback-failed for the lastResult assertion specifically. 129 vitest unit tests + 23 mocha integration tests passing; ts-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(updater): address Qodo #7 (status leak) + #8 (short-drain values) #7. /admin/update/status now redacts diagnostic strings for unauth callers even when requireAdminForStatus is left at its default (false). Status enum + outcome enum are kept (the admin banner / pad-side badge need them to render the right UI) but execution.reason / execution.fromSha / execution.targetTag and the same fields on lastResult are stripped. Authed admin sessions still get the full payload — they're looking at their own server's diagnostics. Two new mocha tests cover both paths: "redacts execution.reason / lastResult.reason for unauth callers" and "returns full diagnostic payload to authed admin sessions". #8. SessionDrainer no longer schedules T-30 / T-10 broadcasts when the configured drainSeconds can't honour them. Previously, with drainSeconds < 30 the T-30 timer fired at zero remaining but the broadcast still claimed "30 seconds" — misleading. Now T-30 only schedules when drainSeconds > 30 and T-10 only when > 10. Admins picking a short drain get fewer announcements but each carries an accurate countdown. The opening announcement now reports the configured drain length rather than a hardcoded 60. Two updated unit tests: drainSeconds=15 (skips T-30, still fires T-10) and drainSeconds=5 (skips both). 131 vitest unit + 26 mocha integration tests passing; ts-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(updater): address Qodo follow-up — tag injection, rollback rejections, state validation Qodo posted three new concerns after the first fix push. 1. Git tag option injection (security). The release tag from GitHub's tag_name flowed into `git checkout` / `git verify-tag` as a positional arg. A tag starting with '-' would be parsed as an option and could bypass signature verification or change checkout semantics. Mitigated in three layers: - New refSafety helper (isValidTag / assertValidTag / refsTagsForm) enforces a strict subset of git's check-ref-format spec: rejects leading '-' or '.', whitespace, control chars, and ~ ^ : ? * [ \\ and the '..' sequence. - VersionChecker validates tag_name before persisting to state, so a malformed value from a misconfigured githubRepo never lands on disk. - UpdateExecutor calls assertValidTag and uses the refs/tags/<tag> form for git checkout. trustedKeys also validates and adds '--' to git verify-tag for an end-of-options marker. updateActions does an up-front isValidTag check on state.latest.tag so a corrupt state file gets a clean 409 instead of a 500. 2. Unhandled rollback rejections. checkPendingVerification was firing `void deps.saveState(...)` and `void performRollback(...)` without .catch(), so an fs error during boot's rollback path would bubble out as an unhandled rejection. Both callsites now go through fireSaveState / fireRollback helpers that catch and log; rollback rejections fall through to a best-effort terminal-state write + exit 75 so the supervisor can re-try the next boot with bootCount++. 3. Execution state under-validated. isValidExecution previously checked only that `status` was a known enum value, so a hand-edited state file with `{execution: {status: 'pending-verification'}}` (missing fromSha / targetTag / deadlineAt) would pass validation and reach RollbackHandler with undefined refs. The validator now consults a per-status required-fields map mirroring the ExecutionStatus union in types.ts and rejects empty strings as well as missing fields. Same tightening applied to lastResult.outcome (must be in the allowed enum, not just any string). Six new unit tests cover hand-edited corruption. 145 vitest + 26 mocha tests green; ts-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f1b524864
commit
efb8328084
45 changed files with 6991 additions and 47 deletions
|
|
@ -14,6 +14,9 @@ const padMessageHandler = require('../../handler/PadMessageHandler');
|
|||
|
||||
let io:any;
|
||||
const logger = log4js.getLogger('socket.io');
|
||||
|
||||
/** Returns the socket.io Server once expressCreateServer has run, or null otherwise. Used by features that need to broadcast outside the regular hook surface. */
|
||||
export const getIo = (): any => io;
|
||||
const sockets = new Set();
|
||||
const socketsEvents = new events.EventEmitter();
|
||||
|
||||
|
|
|
|||
360
src/node/hooks/express/updateActions.ts
Normal file
360
src/node/hooks/express/updateActions.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
'use strict';
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import {spawn} from 'node:child_process';
|
||||
import log4js from 'log4js';
|
||||
import {ArgsExpressType} from '../../types/ArgsExpressType';
|
||||
import settings, {getEpVersion} from '../../utils/Settings';
|
||||
import {getDetectedInstallMethod, stateFilePath, getRollbackDeps} from '../../updater';
|
||||
import {evaluatePolicy} from '../../updater/UpdatePolicy';
|
||||
import {loadState, saveState} from '../../updater/state';
|
||||
import {acquireLock, releaseLock} from '../../updater/lock';
|
||||
import {executeUpdate, SpawnFn} from '../../updater/UpdateExecutor';
|
||||
import {createDrainer, DrainBroadcastKey, Drainer} from '../../updater/SessionDrainer';
|
||||
import {runPreflight} from '../../updater/preflight';
|
||||
import {verifyReleaseTag} from '../../updater/trustedKeys';
|
||||
import {tailLines, appendLine} from '../../updater/updateLog';
|
||||
import {performRollback} from '../../updater/RollbackHandler';
|
||||
import {UpdateState} from '../../updater/types';
|
||||
import {isValidTag} from '../../updater/refSafety';
|
||||
import {getIo} from './socketio';
|
||||
|
||||
const logger = log4js.getLogger('updater');
|
||||
|
||||
const lockPath = (): string => path.join(settings.root, 'var', 'update.lock');
|
||||
const logPath = (): string => path.join(settings.root, 'var', 'log', 'update.log');
|
||||
const backupDir = (): string => path.join(settings.root, 'var', 'update-backup');
|
||||
|
||||
let drainer: Drainer | null = null;
|
||||
|
||||
const requireAdmin = (req: any, res: any): boolean => {
|
||||
const u = req.session?.user;
|
||||
if (!u) { res.status(401).send('Authentication required'); return false; }
|
||||
if (!u.is_admin) { res.status(403).send('Forbidden'); return false; }
|
||||
return true;
|
||||
};
|
||||
|
||||
const wrapAsync =
|
||||
(fn: (req: any, res: any, next: Function) => Promise<unknown>) =>
|
||||
(req: any, res: any, next: Function) => Promise.resolve(fn(req, res, next)).catch((err) => next(err));
|
||||
|
||||
const broadcastShout = (key: DrainBroadcastKey, values: Record<string, unknown>): void => {
|
||||
try {
|
||||
const io = getIo();
|
||||
if (!io) return;
|
||||
// The pad-side renderer (src/static/js/pad.ts) already handles `messageKey`
|
||||
// by routing through html10n.get(); we add a `values` field that the
|
||||
// renderer interpolates into the localised string.
|
||||
const message = {
|
||||
type: 'COLLABROOM',
|
||||
data: {
|
||||
type: 'shoutMessage',
|
||||
payload: {
|
||||
message: {messageKey: key, values, sticky: false},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
io.sockets.emit('shout', message);
|
||||
} catch (err) {
|
||||
logger.warn(`broadcastShout: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildPreflightDeps = (installMethod: ReturnType<typeof getDetectedInstallMethod>) => ({
|
||||
installMethod,
|
||||
workingTreeClean: () => new Promise<boolean>((resolve) => {
|
||||
const c = spawn('git', ['status', '--porcelain'], {cwd: settings.root});
|
||||
let out = '';
|
||||
c.stdout.on('data', (b) => { out += b.toString(); });
|
||||
c.on('close', () => resolve(out.trim().length === 0));
|
||||
c.on('error', () => resolve(false));
|
||||
}),
|
||||
freeDiskMB: async (): Promise<number> => {
|
||||
try {
|
||||
const s = await (fs as any).statfs?.(settings.root);
|
||||
if (!s) return Number.POSITIVE_INFINITY;
|
||||
return Math.floor((Number(s.bavail) * Number(s.bsize)) / (1024 * 1024));
|
||||
} catch {
|
||||
// statfs unsupported on this platform — treat as "no constraint" rather than block.
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
},
|
||||
pnpmOnPath: () => new Promise<boolean>((resolve) => {
|
||||
const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
|
||||
c.on('close', (code) => resolve(code === 0));
|
||||
c.on('error', () => resolve(false));
|
||||
}),
|
||||
// We just acquired the lock in the apply endpoint, so don't double-check it here.
|
||||
lockHeld: async () => false,
|
||||
remoteHasTag: (tag: string) => new Promise<boolean>((resolve) => {
|
||||
const c = spawn('git', ['ls-remote', '--tags', 'origin', tag],
|
||||
{cwd: settings.root, stdio: ['ignore', 'pipe', 'ignore']});
|
||||
let out = '';
|
||||
c.stdout.on('data', (b) => { out += b.toString(); });
|
||||
c.on('close', () => resolve(out.trim().length > 0));
|
||||
c.on('error', () => resolve(false));
|
||||
}),
|
||||
verifyTag: () => verifyReleaseTag({
|
||||
tag: '', // overridden below — we close over targetTag
|
||||
repoDir: settings.root,
|
||||
requireSignature: settings.updates.requireSignature,
|
||||
trustedKeysPath: settings.updates.trustedKeysPath,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The set of update tiers at which the Tier 2 action endpoints serve.
|
||||
* `notify` only ships read-only routes (registered in updateStatus.ts);
|
||||
* `manual` and higher are the supersets that include manual-click. Disabled
|
||||
* paths (off / notify) match prior behaviour: requests 404, no new attack
|
||||
* surface vs PR 1.
|
||||
*
|
||||
* Read at request time (not hook-init time) so that operators flipping
|
||||
* `updates.tier` in settings.json + reloading take effect without a full
|
||||
* restart, and so that integration tests can drive the gate dynamically.
|
||||
*/
|
||||
const TIER2_TIERS: ReadonlySet<string> = new Set(['manual', 'auto', 'autonomous']);
|
||||
const tierAllowsActions = (): boolean => TIER2_TIERS.has(settings.updates.tier);
|
||||
|
||||
export const expressCreateServer = (
|
||||
_hookName: string,
|
||||
{app}: ArgsExpressType,
|
||||
cb: Function,
|
||||
): void => {
|
||||
// Always register the routes; gate at request time so a runtime tier change
|
||||
// takes effect on the next request rather than requiring a restart.
|
||||
// The early 404 below preserves Qodo #1's "disabled path matches prior
|
||||
// behaviour (no Tier 2 endpoints existed before this PR)" requirement.
|
||||
const tierGate = (req: any, res: any, next: Function) => {
|
||||
if (!tierAllowsActions()) return res.status(404).send('Not found');
|
||||
next();
|
||||
};
|
||||
app.use(['/admin/update/apply', '/admin/update/cancel', '/admin/update/acknowledge', '/admin/update/log'], tierGate);
|
||||
|
||||
app.post('/admin/update/apply', wrapAsync(async (req: any, res: any) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const state = await loadState(stateFilePath());
|
||||
if (!state.latest) return res.status(409).json({error: 'no-known-latest'});
|
||||
|
||||
// Defence in depth: VersionChecker validates tag_name before persisting,
|
||||
// but a hand-edited update-state.json could still surface an unsafe tag
|
||||
// here. Reject up-front rather than throw later when the executor calls
|
||||
// assertValidTag, so the admin sees a clear 409 instead of a 500.
|
||||
if (!isValidTag(state.latest.tag)) {
|
||||
return res.status(409).json({error: 'invalid-tag-in-state'});
|
||||
}
|
||||
|
||||
// Allowed entry statuses: idle / verified / preflight-failed / rolled-back.
|
||||
// Anything else means an in-flight or terminal-needs-acknowledge state.
|
||||
const allowedEntry = ['idle', 'verified', 'preflight-failed', 'rolled-back'];
|
||||
if (!allowedEntry.includes(state.execution.status)) {
|
||||
return res.status(409).json({error: `execution-busy:${state.execution.status}`});
|
||||
}
|
||||
|
||||
const installMethod = getDetectedInstallMethod();
|
||||
const policy = evaluatePolicy({
|
||||
installMethod,
|
||||
tier: settings.updates.tier,
|
||||
current: getEpVersion(),
|
||||
latest: state.latest.version,
|
||||
executionStatus: state.execution.status,
|
||||
});
|
||||
if (!policy.canManual) {
|
||||
return res.status(409).json({error: 'policy-denied', reason: policy.reason});
|
||||
}
|
||||
|
||||
if (!await acquireLock(lockPath())) {
|
||||
return res.status(409).json({error: 'lock-held'});
|
||||
}
|
||||
|
||||
const targetTag = state.latest.tag;
|
||||
let cleanupLock = true;
|
||||
|
||||
try {
|
||||
// Persist preflight state.
|
||||
const startedAt = new Date().toISOString();
|
||||
const preState: UpdateState = {
|
||||
...state,
|
||||
execution: {status: 'preflight', targetTag, startedAt},
|
||||
};
|
||||
await saveState(stateFilePath(), preState);
|
||||
appendLine(logPath(), `[${startedAt}] PREFLIGHT target=${targetTag}`);
|
||||
|
||||
const baseDeps = buildPreflightDeps(installMethod);
|
||||
const pf = await runPreflight(
|
||||
{
|
||||
targetTag,
|
||||
diskSpaceMinMB: Number(settings.updates.diskSpaceMinMB) || 500,
|
||||
requireSignature: settings.updates.requireSignature,
|
||||
trustedKeysPath: settings.updates.trustedKeysPath,
|
||||
},
|
||||
{
|
||||
...baseDeps,
|
||||
verifyTag: () => verifyReleaseTag({
|
||||
tag: targetTag,
|
||||
repoDir: settings.root,
|
||||
requireSignature: settings.updates.requireSignature,
|
||||
trustedKeysPath: settings.updates.trustedKeysPath,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!pf.ok) {
|
||||
const at = new Date().toISOString();
|
||||
await saveState(stateFilePath(), {
|
||||
...preState,
|
||||
execution: {status: 'preflight-failed', targetTag, reason: pf.reason, at},
|
||||
lastResult: {
|
||||
targetTag, fromSha: '',
|
||||
outcome: 'preflight-failed', reason: pf.reason, at,
|
||||
},
|
||||
});
|
||||
appendLine(logPath(), `[${at}] PREFLIGHT_FAILED ${pf.reason}`);
|
||||
cleanupLock = true;
|
||||
return res.status(409).json({error: 'preflight-failed', reason: pf.reason});
|
||||
}
|
||||
|
||||
// Re-check state after preflight: /admin/update/cancel may have flipped
|
||||
// execution back to 'idle' while we were running the slow checks. The
|
||||
// cancel handler intentionally leaves the lock alone (we own it) and
|
||||
// signals via state instead, so a stale apply can detect cancellation
|
||||
// here before mutating the filesystem.
|
||||
const afterPreflight = await loadState(stateFilePath());
|
||||
if (afterPreflight.execution.status !== 'preflight'
|
||||
|| (afterPreflight.execution as {targetTag?: string}).targetTag !== targetTag) {
|
||||
appendLine(logPath(),
|
||||
`[${new Date().toISOString()}] APPLY aborted post-preflight (state=${afterPreflight.execution.status})`);
|
||||
return res.status(409).json({error: 'cancelled-during-preflight'});
|
||||
}
|
||||
|
||||
// Drain — respond 202 first so the UI starts polling /log without waiting.
|
||||
const drainSeconds = Number(settings.updates.drainSeconds) || 60;
|
||||
drainer = createDrainer({
|
||||
drainSeconds,
|
||||
broadcast: (key, values) => broadcastShout(key, values),
|
||||
});
|
||||
const drainEndsAt = new Date(Date.now() + drainSeconds * 1000).toISOString();
|
||||
await saveState(stateFilePath(), {
|
||||
...preState,
|
||||
execution: {status: 'draining', targetTag, drainEndsAt, startedAt: new Date().toISOString()},
|
||||
});
|
||||
appendLine(logPath(), `[${new Date().toISOString()}] DRAIN start drainSeconds=${drainSeconds}`);
|
||||
|
||||
res.status(202).json({accepted: true, drainEndsAt});
|
||||
|
||||
const drainResult = await drainer.start();
|
||||
drainer = null;
|
||||
if (drainResult.outcome === 'cancelled') {
|
||||
// /admin/update/cancel already updated state and lastResult; just release the lock.
|
||||
appendLine(logPath(), `[${new Date().toISOString()}] DRAIN cancelled by admin`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-load state right before the executor runs so anything the cancel
|
||||
// endpoint or another concurrent handler wrote is honoured.
|
||||
const fresh = await loadState(stateFilePath());
|
||||
|
||||
const r = await executeUpdate({
|
||||
repoDir: settings.root,
|
||||
backupDir: backupDir(),
|
||||
spawnFn: spawn as unknown as SpawnFn,
|
||||
readSha: () => new Promise<string>((resolve, reject) => {
|
||||
const c = spawn('git', ['rev-parse', 'HEAD'],
|
||||
{cwd: settings.root, stdio: ['ignore', 'pipe', 'ignore']});
|
||||
let out = '';
|
||||
c.stdout.on('data', (b) => { out += b.toString(); });
|
||||
c.on('close', (code) => code === 0
|
||||
? resolve(out.trim())
|
||||
: reject(new Error(`git rev-parse exit ${code}`)));
|
||||
c.on('error', reject);
|
||||
}),
|
||||
copyFile: async (src: string, dst: string) => {
|
||||
await fs.mkdir(path.dirname(dst), {recursive: true});
|
||||
await fs.copyFile(src, dst);
|
||||
},
|
||||
saveState: (s: UpdateState) => saveState(stateFilePath(), s),
|
||||
initialState: fresh,
|
||||
targetTag,
|
||||
now: () => new Date(),
|
||||
// executeUpdate calls exit on success (75) — that takes the process down,
|
||||
// so anything after this is the failure path.
|
||||
exit: (code: number) => process.exit(code),
|
||||
});
|
||||
|
||||
// Failure paths: executor returned without exiting, state is rolling-back.
|
||||
if (r.outcome !== 'pending-verification') {
|
||||
const after = await loadState(stateFilePath());
|
||||
if (after.execution.status === 'rolling-back') {
|
||||
// performRollback will exit 75 on either success or terminal failure.
|
||||
// We do not release the lock — exit takes the process down and the
|
||||
// next-boot acquireLock reaps the stale PID.
|
||||
cleanupLock = false;
|
||||
await performRollback(after, getRollbackDeps());
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`apply failed: ${(err as Error).stack || err}`);
|
||||
appendLine(logPath(), `[${new Date().toISOString()}] APPLY_ERROR ${(err as Error).message}`);
|
||||
if (!res.headersSent) res.status(500).json({error: 'internal'});
|
||||
} finally {
|
||||
if (cleanupLock) {
|
||||
try { await releaseLock(lockPath()); }
|
||||
catch (err) { logger.warn(`releaseLock: ${(err as Error).message}`); }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/admin/update/cancel', wrapAsync(async (req: any, res: any) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const state = await loadState(stateFilePath());
|
||||
// Cancel is allowed only during pre-execute states. Once executing begins
|
||||
// (filesystem mutated) we either complete or rollback — see spec section
|
||||
// "Error handling" / state machine.
|
||||
if (state.execution.status !== 'preflight' && state.execution.status !== 'draining') {
|
||||
return res.status(409).json({error: 'not-cancellable', status: state.execution.status});
|
||||
}
|
||||
if (drainer) drainer.cancel();
|
||||
const at = new Date().toISOString();
|
||||
await saveState(stateFilePath(), {
|
||||
...state,
|
||||
execution: {status: 'idle'},
|
||||
lastResult: {
|
||||
targetTag: (state.execution as {targetTag?: string}).targetTag ?? '',
|
||||
fromSha: '',
|
||||
outcome: 'cancelled',
|
||||
reason: 'admin-cancelled',
|
||||
at,
|
||||
},
|
||||
});
|
||||
// Intentionally do NOT release the lock here. The apply handler owns the
|
||||
// lock for its lifetime and releases it in its finally block; releasing
|
||||
// here would let a second apply slip in while the first is still mid-
|
||||
// preflight, racing for the same on-disk state.
|
||||
appendLine(logPath(), `[${at}] CANCEL by admin during status=${state.execution.status}`);
|
||||
res.json({cancelled: true});
|
||||
}));
|
||||
|
||||
app.post('/admin/update/acknowledge', wrapAsync(async (req: any, res: any) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const state = await loadState(stateFilePath());
|
||||
const terminal: ReadonlySet<string> = new Set(['rollback-failed', 'preflight-failed', 'rolled-back']);
|
||||
if (!terminal.has(state.execution.status)) {
|
||||
return res.status(409).json({error: 'not-terminal', status: state.execution.status});
|
||||
}
|
||||
await saveState(stateFilePath(), {...state, execution: {status: 'idle'}, bootCount: 0});
|
||||
appendLine(logPath(), `[${new Date().toISOString()}] ACKNOWLEDGE ${state.execution.status} -> idle`);
|
||||
res.json({acknowledged: true});
|
||||
}));
|
||||
|
||||
app.get('/admin/update/log', wrapAsync(async (req: any, res: any) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const lines = await tailLines(logPath(), 200);
|
||||
res.set('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(lines.join('\n'));
|
||||
}));
|
||||
|
||||
cb();
|
||||
};
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
'use strict';
|
||||
|
||||
import path from 'node:path';
|
||||
import {ArgsExpressType} from '../../types/ArgsExpressType';
|
||||
import settings, {getEpVersion} from '../../utils/Settings';
|
||||
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
|
||||
import {evaluatePolicy} from '../../updater/UpdatePolicy';
|
||||
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare';
|
||||
import {loadState} from '../../updater/state';
|
||||
import {isHeld} from '../../updater/lock';
|
||||
|
||||
|
||||
let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0};
|
||||
|
|
@ -37,6 +39,23 @@ const wrapAsync = (fn: (req: any, res: any, next: Function) => Promise<unknown>)
|
|||
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip diagnostic strings (reason, fromSha, targetTag, build/install paths)
|
||||
* from execution before exposing to unauthenticated callers. Status enum is
|
||||
* preserved so the admin banner / pad-side badge can still render the right UI.
|
||||
*/
|
||||
const sanitizeExecution = (e: any): any => {
|
||||
if (!e || typeof e !== 'object' || typeof e.status !== 'string') return {status: 'idle'};
|
||||
return {status: e.status};
|
||||
};
|
||||
|
||||
const sanitizeLastResult = (r: any): any => {
|
||||
if (r === null) return null;
|
||||
if (!r || typeof r !== 'object' || typeof r.outcome !== 'string') return null;
|
||||
// outcome enum + at timestamp are non-sensitive. reason / fromSha / targetTag are dropped.
|
||||
return {outcome: r.outcome, at: typeof r.at === 'string' ? r.at : null};
|
||||
};
|
||||
|
||||
export const expressCreateServer = (
|
||||
_hookName: string,
|
||||
{app}: ArgsExpressType,
|
||||
|
|
@ -68,6 +87,7 @@ export const expressCreateServer = (
|
|||
// release. Admins who want the endpoint gated to authenticated admin sessions —
|
||||
// without disabling the updater entirely — set updates.requireAdminForStatus=true.
|
||||
app.get('/admin/update/status', wrapAsync(async (req, res) => {
|
||||
const isAdmin = !!req.session?.user?.is_admin;
|
||||
if (settings.updates.requireAdminForStatus) {
|
||||
const user = req.session?.user;
|
||||
if (!user) return res.status(401).send('Authentication required');
|
||||
|
|
@ -77,8 +97,29 @@ export const expressCreateServer = (
|
|||
const current = getEpVersion();
|
||||
const installMethod = getDetectedInstallMethod();
|
||||
const policy = state.latest
|
||||
? evaluatePolicy({installMethod, tier: settings.updates.tier, current, latest: state.latest.version})
|
||||
? evaluatePolicy({
|
||||
installMethod,
|
||||
tier: settings.updates.tier,
|
||||
current,
|
||||
latest: state.latest.version,
|
||||
executionStatus: state.execution.status,
|
||||
})
|
||||
: null;
|
||||
const lockHeld = await isHeld(path.join(settings.root, 'var', 'update.lock'));
|
||||
|
||||
// The Tier 2 fields (execution, lastResult) carry diagnostic strings
|
||||
// built from git/pnpm stderr — environment-specific paths, error
|
||||
// messages, etc. Endpoint defaults to unauthenticated; only authed
|
||||
// admin sessions see the full diagnostic payload. Everyone else sees
|
||||
// just the status enum + outcome enum so the pad-side / public banners
|
||||
// can still render correctly without leaking operational detail.
|
||||
const execution = isAdmin
|
||||
? state.execution
|
||||
: sanitizeExecution(state.execution);
|
||||
const lastResult = isAdmin
|
||||
? state.lastResult
|
||||
: sanitizeLastResult(state.lastResult);
|
||||
|
||||
res.json({
|
||||
currentVersion: current,
|
||||
latest: state.latest,
|
||||
|
|
@ -87,6 +128,10 @@ export const expressCreateServer = (
|
|||
tier: settings.updates.tier,
|
||||
policy,
|
||||
vulnerableBelow: state.vulnerableBelow,
|
||||
// PR 2 additions:
|
||||
execution,
|
||||
lastResult,
|
||||
lockHeld,
|
||||
});
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue