etherpad-lite/doc/admin/updates.md
John McLear 962bfe8649
feat(updater): tier 4 — autonomous update in maintenance window (#7607) (#7753)
* 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>
2026-05-17 13:50:34 +01:00

18 KiB
Raw Blame History

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, runs git 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 scheduled and is applied after preApplyGraceMinutes. During the grace window, /admin/update shows a live countdown plus Cancel and Apply now buttons; an admin email (if adminEmail is set) fires once per scheduled tag.
  • Tier 4 (autonomous in maintenance window) — opt-in. Tier 3 + updates.maintenanceWindow is 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 a vulnerable-below threshold 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:

  1. /.dockerenv exists → "docker".
  2. .git/ directory present and the install root is writable → "git".
  3. package-lock.json present and writable → "npm".
  4. 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=5 to 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

  1. Lock acquirevar/update.lock (PID-based, stale locks reaped automatically).
  2. Pre-flight checks — install method writable, working tree clean, free disk ≥ diskSpaceMinMB, pnpm on PATH, target tag exists at the configured remote, signature verifies (if requireSignature: true). On failure, state goes to preflight-failed with a typed reason; the admin sees a banner and clicks Acknowledge to clear it. No filesystem mutation has happened — nothing to roll back.
  3. DraindrainSeconds window 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.
  4. Executegit fetch --tags origin, git checkout <tag>, pnpm install --frozen-lockfile, pnpm run build:ui. Output streams to var/log/update.log (rotated 10 MB × 5).
  5. Exit 75 — the supervisor restarts on the new version.
  6. Health check — RollbackHandler arms a rollbackHealthCheckSeconds timer at boot. When /health responds 200 (i.e., Etherpad reaches the RUNNING state) the timer cancels and the state lands on verified.

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. Returns 202 {accepted, drainEndsAt} once the drain begins. Body unused.
  • POST /admin/update/cancel — cancel during pre-flight or drain. Returns 409 once the executor has begun mutating the filesystem (state machine guarantees we either complete or roll back from there).
  • POST /admin/update/acknowledge — clear a terminal preflight-failed / rolled-back / rollback-failed state back to idle.
  • GET /admin/update/log — tail the last 200 lines of var/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

  1. The periodic version checker (updates.checkIntervalHours) hits GitHub Releases.
  2. If policy.canAuto is true (install is git, no terminal rollback-failed state, tier is "auto" or "autonomous"), the scheduler transitions execution.status to scheduled with scheduledFor = now + preApplyGraceMinutes.
  3. 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.
  4. /admin/update shows a live countdown panel plus two buttons:
    • CancelPOST /admin/update/cancel returns the state to idle and drops the in-process timer.
    • Apply nowPOST /admin/update/apply skips the remaining grace; the regular Tier 2 pipeline runs immediately.
  5. 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.graceStartTag dedupe field guards against duplicate grace-start notifications.
  • If updates.tier is flipped back to "manual" or "notify" while a schedule is pending, the next periodic check cancels the schedule (state back to idle).
  • rollback-failed disables Tier 3 globally. The admin must POST /admin/update/acknowledge (or visit /admin/update and 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: docker and 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 -d for 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:0002:00 runs from 22:00 through 01:59).

How the window gate works

  1. evaluatePolicy returns canAutonomous: true only when the install is git, tier is "autonomous", no terminal rollback-failed is set, and updates.maintenanceWindow is set and parse-valid. Missing/malformed windows return canAutonomous: false with policy.reason equal to maintenance-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.
  2. When the scheduler picks up a new release while canAutonomous: true, it computes scheduledFor = now + preApplyGraceMinutes. If that timestamp falls outside the window, it is snapped forward to the next opening of the window.
  3. 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.json is updated with a new scheduledFor pointing 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:30 in America/New_York on the second Sunday of March) silently lands at the next valid wall-clock minute via the host JS Date constructor'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:MMHH: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 scheduledFor and 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.