mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* docs(updater): plan tier 4 — autonomous update in maintenance window (#7607) Maps PR 4 of the auto-update design spec (§"Tier 4 — autonomous") to concrete files, tasks, and verification steps. Subsequent commits scaffold against this plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): MaintenanceWindow module — wall-clock window math for tier 4 Pure module: parseWindow, inWindow, nextWindowStart. Supports tz=local|utc and cross-midnight ranges. Used by upcoming Scheduler + UpdatePolicy changes. 22 vitest unit tests cover format validation, same-day + cross-midnight boundaries, and host-local vs UTC clock comparisons. DST handling is absorbed by JS Date constructor's wall-clock normalization (documented in the file header). Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): tier 4 backend — window-gated UpdatePolicy + Scheduler Wires MaintenanceWindow into the existing tier 3 backend so autonomous updates only fire while `now` is inside `updates.maintenanceWindow`. UpdatePolicy - new optional `maintenanceWindow` input - canAutonomous flips on only for git+tier=autonomous+parse-valid window - new reasons `maintenance-window-missing` / `maintenance-window-invalid` - rollback-failed still wins over window denial Scheduler - decideSchedule snaps scheduledFor forward to nextWindowStart when canAutonomous + grace lands outside the window - decideTriggerApply returns a new `{action: 'defer'}` when canAutonomous + fire-time is outside the window; carries nextStart for the runner - canAutonomous=false preserves Tier 3 behavior unchanged index.ts wires settings.updates.maintenanceWindow through both passes and re-arms the timer on defer. Status endpoint surface (nextWindowOpensAt) + admin UI picker land in a follow-up commit. Settings adds `maintenanceWindow: {start, end, tz} | null`, defaulting to null. settings.json.template / settings.json.docker document the shape. Tests - 22 vitest cases for MaintenanceWindow already cover the math - 4 new UpdatePolicy cases for the window outcomes - 6 new Scheduler cases for tier-4 schedule/trigger paths - Full backend-new suite: 629 passed (35 files) Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): tier 4 admin UI — window status, deferred subtitle, banner GET /admin/update/status now returns: - `maintenanceWindow`: the parsed window object (admin sessions only) - `nextWindowOpensAt`: ISO of the next window opening when tier=autonomous UpdatePage - new "Maintenance window" section when tier=autonomous, shows current window summary + next opens at, or "Not configured" when unset - scheduled panel now appends a "deferred until <iso>" line when the backend has snapped scheduledFor to the next window opening UpdateBanner - new variant when tier=autonomous and policy.reason is `maintenance-window-missing` or `maintenance-window-invalid`, linking to /admin/update i18n - 8 new keys under `update.banner.*`, `update.page.policy.*`, `update.page.scheduled.*`, `update.window.*` (en.json only; translations follow via the usual locale workflow) Interactive picker is intentionally deferred — admins edit `updates.maintenanceWindow` via the parsed JSONC settings editor (#7709). A follow-up commit may add a thin write-through component if the JSONC round-trip turns out to be too rough for typical operators. Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(updater): tier 4 — window gate, DST notes, runbook §12 (#7607) CHANGELOG: flip Tier 4 from "designed, not yet implemented" to current. Document maintenanceWindow shape, snap-forward, defer-at-fire, and the two missing/invalid policy reasons. doc/admin/updates.md: new "Tier 4 — autonomous in a maintenance window" section with config example, policy gating, DST/timezone notes, admin UI behavior. runbook: §12 walks a disposable VM through missing-window, malformed, outside-window deferral, fire-at-opening, and window-closes-mid-grace. Adds five sign-off checklist items. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(updater): tier 4 window-boundary integration (#7607) Mocha integration covering the four scenarios called out in the spec §"Tier 4 — autonomous": - outside-window: decideSchedule snaps scheduledFor forward to the next opening and the snapped value round-trips through saveState - inside-window at fire-time: decideTriggerApply returns fire - window-closes-mid-grace: decideTriggerApply returns defer with nextStart at the next opening; persisted state moves forward - cancel during deferred-grace: state returns to idle, and the next decideSchedule pass re-emits a schedule snapped to the next opening All 4 cases passing locally under tsx mocha. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): real SMTP via nodemailer (mail.* settings) (#7607) Replaces the (would send email) stub introduced in PR #7601 with a nodemailer-backed transport. The dependency is lazy-imported so installs that don't set mail.host pay no runtime cost. Settings additions - new top-level mail block: host, port, secure, from, auth (user/pass) - mail.host=null keeps the legacy log-only behaviour; the Notifier still updates dedupe state so we don't re-evaluate every tick - settings.json.template documents the shape inline - settings.json.docker reads MAIL_HOST / MAIL_FROM / MAIL_PORT / MAIL_SECURE from env so operators can configure via container env Transport - lazy import('nodemailer') on first send - transport cached by host; settings reload picks up new host without needing a restart - send errors are swallowed (logged warn) so a transient SMTP failure can never poison the surrounding updater state machine - successful sends log at info; legacy "(would send email)" path remains the visible signal when mail is disabled Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): preflight checks target tag's engines.node (#7607) Before mutating the working tree, runPreflight now reads the target tag's package.json via `git show <tag>:package.json` and verifies that process.versions.node satisfies its engines.node range. Failures land at preflight-failed cleanly (no rollback needed — nothing has changed yet). Motivation: a release that bumps the Node floor used to either fail mid-`pnpm install` (which then rolls back successfully) or restart on the new build and crash in the boot path (which then rolls back via the health-check timer). Both paths recover, but they burn a drain + restart cycle on a condition we can reject upfront. Implementation - new PreflightReason `node-engine-mismatch` - new dep `readTargetEnginesNode(tag)` — runs the git-show as a child process with stdio captured to a string; missing tag / missing file / malformed JSON / missing engines.node all resolve to null (treated as "no constraint, pass") - uses existing semver dep with includePrerelease: true - new PreflightInput field `currentNodeVersion`; threaded from process.versions.node in both wirings (scheduler + manual apply) - check runs *after* signature verification so we trust the package.json - PreflightResult carries an optional `detail` string; applyPipeline appends it to the lastResult.reason so the admin UI shows e.g. "node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0" Tests: 6 new vitest cases (no engines.node, satisfies, fails below floor, caret range, loose-spaced range, ordering after signature). Full backend-new: 635 passed (was 629). Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): email admin on auto-rollback / preflight-failed (#7607) Before this commit, only the terminal rollback-failed state emailed the admin. Auto-recovered failures (rolled-back-install-failed, rolled-back- build-failed, rolled-back-health-check, rolled-back-crash-loop) and pre- flight-failed surfaced only via the /admin/update banner — so a 3am autonomous update that failed because of, say, a Node engine bump would roll back silently and stay invisible until the admin next logged in. Notifier - new EmailKinds: 'update-preflight-failed', 'update-rolled-back', 'update-rollback-failed' - new pure decideOutcomeEmail(input) → {toSend, newState} - dedupe key `<outcome>:<targetTag>` in EmailSendLog.lastFailureKey: same outcome on same tag emits one email per cycle (kills retry-loop spam); a different outcome or different tag resets the key - rollback-failed always fires (terminal — overrides dedupe) - state.ts validator + loadState backfill the new field for legacy state files (Tier 1/2/3 installs upgrading in place) Wiring - new index.ts helper notifyApplyFailure() loads state, runs the pure notifier, sends (via the nodemailer-backed sendEmailViaSmtp from the previous commit), persists the new dedupe key — all best-effort - schedulerTriggerApply: fires on applyUpdate returning preflight-failed or rolled-back - /admin/update/apply HTTP handler: same - boot path in expressCreateServer: if state.lastResult is a failure outcome we haven't already emailed about, fire then. Covers: - health-check timeout rollback (timer expired between boots) - crash-loop forced rollback caught on a later boot - preflight-failed where the process didn't get to email before exit - unacknowledged rollback-failed terminal Tests - 8 new vitest cases for decideOutcomeEmail (adminEmail=null, each outcome's content, dedupe by tag, dedupe by outcome, rollback-failed bypass) - Full backend-new suite: 643 passed (was 635) Refs #7607 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(updater): address Qodo review on tier 4 - UpdatePage: only show "deferred until" subtitle when scheduledFor actually matches nextWindowOpensAt. The previous `scheduledFor > now + 60s` heuristic misfired during a normal in-window 15-min grace period. - applyPipeline: return the enriched preflight reason (`reason: detail`) instead of only `pf.reason`, so /admin/update/apply 409 bodies and failure-notify emails preserve diagnostics like the Node engine mismatch detail. - updater/index: key the cached nodemailer transport on the full set of SMTP options (host + port + secure + auth) so runtime changes to port/credentials via reloadSettings() invalidate the cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
4.1 KiB
TypeScript
120 lines
4.1 KiB
TypeScript
import {create} from "zustand";
|
|
import {Socket} from "socket.io-client";
|
|
import {PadSearchResult} from "../utils/PadSearch.ts";
|
|
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
|
|
import {InstalledPlugin} from "../pages/Plugin.ts";
|
|
|
|
export type Execution =
|
|
| {status: 'idle'}
|
|
| {status: 'scheduled'; targetTag: string; scheduledFor: string; startedAt: string}
|
|
| {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 MaintenanceWindow {
|
|
start: string;
|
|
end: string;
|
|
tz: 'local' | 'utc';
|
|
}
|
|
|
|
export interface UpdateStatusPayload {
|
|
currentVersion: string;
|
|
latest: null | {
|
|
version: string;
|
|
tag: string;
|
|
body: string;
|
|
publishedAt: string;
|
|
prerelease: boolean;
|
|
htmlUrl: string;
|
|
};
|
|
lastCheckAt: string | null;
|
|
installMethod: string;
|
|
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;
|
|
// Tier 4 additions:
|
|
maintenanceWindow: MaintenanceWindow | null;
|
|
nextWindowOpensAt: string | null;
|
|
}
|
|
|
|
type ToastState = {
|
|
description?:string,
|
|
title: string,
|
|
open: boolean,
|
|
success: boolean
|
|
}
|
|
|
|
|
|
type StoreState = {
|
|
settings: string|undefined,
|
|
setSettings: (settings: string) => void,
|
|
settingsSocket: Socket|undefined,
|
|
setSettingsSocket: (socket: Socket) => void,
|
|
showLoading: boolean,
|
|
setShowLoading: (show: boolean) => void,
|
|
setPluginsSocket: (socket: Socket) => void
|
|
pluginsSocket: Socket|undefined,
|
|
toastState: ToastState,
|
|
setToastState: (val: ToastState)=>void,
|
|
pads: PadSearchResult|undefined,
|
|
setPads: (pads: PadSearchResult)=>void,
|
|
installedPlugins: InstalledPlugin[],
|
|
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
|
|
updateStatus: UpdateStatusPayload | null,
|
|
setUpdateStatus: (s: UpdateStatusPayload) => void,
|
|
updateLog: string,
|
|
setUpdateLog: (log: string) => void,
|
|
authors: AuthorSearchResult|undefined,
|
|
setAuthors: (authors: AuthorSearchResult)=>void,
|
|
gdprAuthorErasureEnabled: boolean,
|
|
setGdprAuthorErasureEnabled: (enabled: boolean)=>void,
|
|
}
|
|
|
|
|
|
export const useStore = create<StoreState>()((set) => ({
|
|
settings: undefined,
|
|
setSettings: (settings: string) => set({settings}),
|
|
settingsSocket: undefined,
|
|
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
|
|
showLoading: false,
|
|
setShowLoading: (show: boolean) => set({showLoading: show}),
|
|
pluginsSocket: undefined,
|
|
setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),
|
|
setToastState: (val )=>set({toastState: val}),
|
|
toastState: {
|
|
open: false,
|
|
title: '',
|
|
description:'',
|
|
success: false
|
|
},
|
|
pads: undefined,
|
|
setPads: (pads)=>set({pads}),
|
|
installedPlugins: [],
|
|
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
|
|
updateStatus: null,
|
|
setUpdateStatus: (s) => set({updateStatus: s}),
|
|
updateLog: '',
|
|
setUpdateLog: (log) => set({updateLog: log}),
|
|
authors: undefined,
|
|
setAuthors: (authors)=>set({authors}),
|
|
gdprAuthorErasureEnabled: false,
|
|
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
|
|
}));
|