* 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>
18 KiB
Etherpad updates
Etherpad ships with a built-in update subsystem.
- Tier 1 (notify) — default. A banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No execution.
- Tier 2 (manual click) — admins on a git install can click "Apply update" at
/admin/update. Etherpad drains active sessions, runsgit fetch / checkout / pnpm install / pnpm run build:ui, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure. - Tier 3 (auto with grace window) — opt-in. On a git install, a newly detected release transitions execution state to
scheduledand is applied afterpreApplyGraceMinutes. During the grace window,/admin/updateshows a live countdown plus Cancel and Apply now buttons; an admin email (ifadminEmailis set) fires once per scheduled tag. - Tier 4 (autonomous in maintenance window) — opt-in. Tier 3 +
updates.maintenanceWindowis required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening.
Settings
In settings.json:
{
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false,
// Tier 2+ knobs (only meaningful at tier "manual" or higher):
"preApplyGraceMinutes": 0,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"requireSignature": false,
"trustedKeysPath": null
},
"adminEmail": null
}
| Setting | Default | Notes |
|---|---|---|
updates.tier |
"notify" |
One of "off", "notify", "manual", "auto", "autonomous". Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors "notify" and "off". |
updates.source |
"github" |
Reserved for future alternative sources. Only "github" is implemented. |
updates.channel |
"stable" |
Reserved. Stable releases only. |
updates.installMethod |
"auto" |
One of "auto", "git", "docker", "npm", "managed". Auto-detects via filesystem heuristics. Set explicitly to override. |
updates.checkIntervalHours |
6 |
How often to poll GitHub Releases. |
updates.githubRepo |
"ether/etherpad" |
Override for forks. |
updates.requireAdminForStatus |
false |
Lock the /admin/update/status endpoint to authenticated admin sessions. Default false matches existing Etherpad behavior — /health already exposes releaseId publicly, and changelog data comes from a public GitHub release. Set true to hide the full update payload from non-admins without disabling the updater (tier: "off" is the heavier opt-out that removes the endpoints entirely). |
updates.preApplyGraceMinutes |
0 |
Tier 3 only. Wait this many minutes between detecting a new release and starting the drain so the admin can cancel via /admin/update. 0 applies immediately when allowed. Clamped to [0, 7*24*60] (one week). Has no effect at tier "manual". |
updates.drainSeconds |
60 |
How long to broadcast "restart imminent" announcements to active pads before exiting. T-60 / T-30 / T-10 broadcasts fire automatically at the matching offsets within this window. |
updates.rollbackHealthCheckSeconds |
60 |
After a fresh boot post-update, give /health this long to come up. If it doesn't, RollbackHandler restores the previous SHA. |
updates.diskSpaceMinMB |
500 |
Pre-flight refuses to start an update unless the install volume has at least this many MB free. |
updates.requireSignature |
false |
When true, refuse updates whose tag is not signed by a trusted key. Verification is done via git verify-tag <tag> against the user's GPG keyring. Default false because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set true if you run your own builds or have imported a fork's keys. |
updates.trustedKeysPath |
null |
Override the keyring location passed to git verify-tag via the $GNUPGHOME env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when requireSignature: true. |
adminEmail |
null |
Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
What "outdated" means
severe— running at least one major version behind the latest release.vulnerable— the running version is below avulnerable-belowthreshold announced in a recent release. Releases declare these via a<!-- updater: vulnerable-below X.Y.Z -->HTML comment in their body. The newest such directive wins.
Email cadence (when adminEmail is set)
| Trigger | First send | Repeat |
|---|---|---|
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
| Severely outdated detected | Immediate | Monthly while still severely outdated |
| Up to date | No email | — |
If adminEmail is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with (would send email) until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
Pad-side badge
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
- The instance is
severe(one or more major versions behind), or - The instance is
vulnerable(running below an announced threshold).
The public endpoint /api/version-status returns only {outdated: null|"severe"|"vulnerable"} — it never leaks the running version, so attackers do not gain a fingerprint vector.
Disabling everything
Set updates.tier to "off". No HTTP request will leave the instance and no banner or badge will render.
Privacy
The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (api.github.com/repos/<repo>/releases/latest) with If-None-Match to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a User-Agent: etherpad-self-update header. No instance ID, no version, no identifiers travel upstream.
How install method is detected
updates.installMethod defaults to "auto", which uses these heuristics in order:
/.dockerenvexists →"docker"..git/directory present and the install root is writable →"git".package-lock.jsonpresent and writable →"npm".- Otherwise →
"managed".
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only "git" is initially supported for write tiers.
Tier 2 — manual click
Tier 2 is opt-in. To enable: set updates.tier: "manual" and ensure your install was deployed via git (not docker / npm / managed package).
Process supervisor is required
Etherpad applies an update by exiting with code 75 so a process supervisor restarts it. Without a supervisor the instance simply exits and stays down. Common supervisor setups:
- systemd: add
Restart=on-failure+RestartSec=5to your unit file. - pm2: the default behaviour restarts on exit.
- docker: add
--restart=unless-stopped(Tier 2 itself is not supported on docker installs anyway, but if you wrap your own image around a git checkout this applies).
What clicking "Apply update" does
- Lock acquire —
var/update.lock(PID-based, stale locks reaped automatically). - Pre-flight checks — install method writable, working tree clean, free disk ≥
diskSpaceMinMB,pnpmonPATH, target tag exists at the configured remote, signature verifies (ifrequireSignature: true). On failure, state goes topreflight-failedwith a typed reason; the admin sees a banner and clicks Acknowledge to clear it. No filesystem mutation has happened — nothing to roll back. - Drain —
drainSecondswindow during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click Cancel during this window to abort cleanly. - Execute —
git fetch --tags origin,git checkout <tag>,pnpm install --frozen-lockfile,pnpm run build:ui. Output streams tovar/log/update.log(rotated 10 MB × 5). - Exit 75 — the supervisor restarts on the new version.
- Health check — RollbackHandler arms a
rollbackHealthCheckSecondstimer at boot. When/healthresponds 200 (i.e., Etherpad reaches theRUNNINGstate) the timer cancels and the state lands onverified.
Failure modes
| What went wrong | Resulting state | Admin action |
|---|---|---|
| Pre-flight check fails | preflight-failed |
Click Acknowledge after fixing the underlying issue (free up disk, clean working tree, etc.). |
git fetch / git checkout fails mid-flow |
rolled-back |
Informational. The working tree is back where it started; click Acknowledge to clear. |
pnpm install or pnpm run build:ui fails |
rolled-back |
Same as above. The lockfile and SHA are restored. |
/health doesn't come up within rollbackHealthCheckSeconds |
rolled-back |
Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
The new version crashes at boot more than twice (bootCount > 2) |
rolled-back |
Crash-loop guard kicks in regardless of the health-check timer. |
Rollback itself fails (e.g., pnpm install errors restoring old lockfile) |
rollback-failed |
Manual intervention required. The admin banner switches to a strong red alert. Restore the install by hand, then click Acknowledge to clear the lock and re-allow Tier 2 attempts. |
Endpoints
All Tier 2 endpoints require an authenticated admin session (is_admin: true) regardless of requireAdminForStatus.
POST /admin/update/apply— start an apply. Returns202 {accepted, drainEndsAt}once the drain begins. Body unused.POST /admin/update/cancel— cancel during pre-flight or drain. Returns409once the executor has begun mutating the filesystem (state machine guarantees we either complete or roll back from there).POST /admin/update/acknowledge— clear a terminalpreflight-failed/rolled-back/rollback-failedstate back toidle.GET /admin/update/log— tail the last 200 lines ofvar/log/update.log. Plain text. Used by the in-progress UI.
Signature verification
Default off. Etherpad releases are not yet consistently signed; turning verification on by default would block every Tier 2 update. To enable:
"updates": {
"requireSignature": true,
"trustedKeysPath": "/srv/etherpad/keys" // optional — defaults to the OS user keyring
}
The check shells out to git verify-tag <tag>. The keyring at trustedKeysPath is passed to git via GNUPGHOME. If trustedKeysPath is null (default), the OS user's default keyring is used.
Docker-friendly update flows (future work)
Tier 2 deliberately refuses to apply on installMethod: "docker" because in-container git fetch / pnpm install / build:ui doesn't survive a container restart — the orchestrator brings the container back up on the same image tag and the work is lost. Docker installs stay on Tier 1 (banner + version status) for now.
Tier 3 — auto with grace window
Tier 3 builds on Tier 2 by scheduling the apply automatically when a new release is detected. The same git fetch / checkout / pnpm install / build:ui / exit 75 pipeline runs — only the trigger changes.
To enable, on a git install: set updates.tier: "auto" and (optionally) updates.preApplyGraceMinutes to the grace duration you want.
What happens when a new release lands
- The periodic version checker (
updates.checkIntervalHours) hits GitHub Releases. - If
policy.canAutois true (install is git, no terminalrollback-failedstate, tier is"auto"or"autonomous"), the scheduler transitionsexecution.statustoscheduledwithscheduledFor = now + preApplyGraceMinutes. - The schedule is persisted to
var/update-state.json, so an Etherpad restart inside the grace window rehydrates the timer rather than losing the schedule. /admin/updateshows a live countdown panel plus two buttons:- Cancel —
POST /admin/update/cancelreturns the state toidleand drops the in-process timer. - Apply now —
POST /admin/update/applyskips the remaining grace; the regular Tier 2 pipeline runs immediately.
- Cancel —
- When the timer fires, the scheduler runs the exact same pipeline as a manual Tier 2 click: pre-flight → drain → execute → exit 75.
Re-scheduling and stale state
- If a newer release tag appears while a schedule is pending, the scheduler re-arms the timer for the new tag. The
email.graceStartTagdedupe field guards against duplicategrace-startnotifications. - If
updates.tieris flipped back to"manual"or"notify"while a schedule is pending, the next periodic check cancels the schedule (state back toidle). rollback-faileddisables Tier 3 globally. The admin mustPOST /admin/update/acknowledge(or visit/admin/updateand click Acknowledge) before any further auto-schedules are armed. Tier 2 manual click stays available because the admin click is the intervention the terminal state requires.
Email (adminEmail set)
A single grace-start notification fires per scheduled tag:
[Etherpad] Auto-update scheduled for 2.7.2
with the scheduledFor timestamp. Etherpad core does not yet wire SMTP; the message logs as (would send email) until a future PR adds a transport. Cadence and dedupe still update correctly.
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
- Instructions-only. When the page detects
installMethod: dockerand a newer release exists, swap the policy-denial copy for actionable instructions (docker pull etherpad/etherpad:<tag>for plain docker;docker compose pull && docker compose up -dfor compose). Cheap, no new attack surface. - Deploy webhook. New setting
updates.dockerWebhook. When set, the Apply button on a docker install POSTs to the configured URL and trusts the orchestrator (Render / Railway / Fly / Portainer / Coolify / GitHub Actions — they all expose redeploy webhooks) to do the actual pull-and-recreate.
Direct Docker-socket access (mount /var/run/docker.sock into the container) is out of scope — anyone who escapes the Etherpad process via that socket gets root on the host. Admins who want fully autonomous docker updates should run Watchtower alongside Etherpad rather than bake equivalent privilege into Etherpad itself.
Tier 4 — autonomous in a maintenance window
Tier 4 layers a wall-clock window on top of Tier 3 so autonomous updates only run while it is safe to drain sessions (typically nightly).
To enable, on a git install:
{
"updates": {
"tier": "autonomous",
"preApplyGraceMinutes": 15,
"maintenanceWindow": { "start": "03:00", "end": "05:00", "tz": "local" }
}
}
start and end are 24-hour HH:MM wall-clock times in the configured tz ("local" or "utc"). end is exclusive; end < start denotes a cross-midnight window (22:00–02:00 runs from 22:00 through 01:59).
How the window gate works
evaluatePolicyreturnscanAutonomous: trueonly when the install isgit, tier is"autonomous", no terminalrollback-failedis set, andupdates.maintenanceWindowis set and parse-valid. Missing/malformed windows returncanAutonomous: falsewithpolicy.reasonequal tomaintenance-window-missing/maintenance-window-invalid, and the rest of the policy degrades to Tier 3 (canAuto: true). An admin banner surfaces the misconfiguration so the autonomous behavior is never silently disabled.- When the scheduler picks up a new release while
canAutonomous: true, it computesscheduledFor = now + preApplyGraceMinutes. If that timestamp falls outside the window, it is snapped forward to the next opening of the window. - When the timer fires, the scheduler re-checks the clock. If the window has already closed (long grace, clock skew, host suspend), the fire is deferred:
var/update-state.jsonis updated with a newscheduledForpointing at the next opening, the timer is re-armed, and the actual apply runs at the next valid moment.
DST and timezone notes
tz: "utc"is recommended for hosts running across DST boundaries — the window is interpreted against the same wall clock every day of the year.tz: "local"follows the host's local time. On DST spring-forward days, a window starting at a non-existent local time (e.g.02:30inAmerica/New_Yorkon the second Sunday of March) silently lands at the next valid wall-clock minute via the host JSDateconstructor's normalization. On fall-back days, the first occurrence of the wall-clock start time is used.- Cross-midnight windows (
end < start) span at most 24 hours; longer "windows" should be split into two settings, e.g. by running Tier 3 instead.
Admin UI
/admin/update shows a "Maintenance window" section when updates.tier == "autonomous":
- Configured: summary
HH:MM–HH:MM (tz)plus "Next window opens at …". - Not configured: a clear "Not configured" message and a top-of-page banner that links back to the page.
- During a deferred-grace schedule, the scheduled panel shows both the countdown to
scheduledForand an explanatory "Outside maintenance window. Update will start when the window opens at …" line.
Admins edit updates.maintenanceWindow via the parsed JSONC settings editor at /admin/settings. Saving an invalid shape is caught at boot — the warning is logged via the updater log4js category and the policy downgrades to Tier 3.