Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ Both clients hit the **stable 3.x API surface**, so server operators don't need
- **Self-update subsystem — Tier 3 (auto with grace window).**
- On a git install, set `updates.tier: "auto"` to have new releases applied automatically after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons. Schedules are persisted to `var/update-state.json`, so an Etherpad restart during the grace window rehydrates the timer instead of losing the schedule. A new release tag detected mid-grace re-arms the timer; if `adminEmail` is set, a one-shot `grace-start` notification fires per scheduled tag (issue #7607).
- The terminal `rollback-failed` state continues to disable auto/autonomous attempts globally until acknowledged; manual click stays available because an admin click *is* the intervention the terminal state requires.
- Tier 4 (autonomous in a maintenance window) remains designed but unimplemented and will land in a subsequent release.
- **Self-update subsystem — Tier 4 (autonomous in a maintenance window).**
- Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by the host's wall-clock arithmetic.
- A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behavior is not silently disabled. Closes #7607.
- **Privacy — drop swagger-ui telemetry, document phone-homes, add opt-outs.**
- Dropped `swagger-ui-express` because upstream injects a Scarf analytics pixel that cannot be disabled at install or runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)). `/api-docs` now serves a vendored copy of [Scalar](https://github.com/scalar/scalar) (MIT) configured with `withDefaultFonts: false` and `telemetry: false` so no outbound calls are made.
- New `privacy.updateCheck` (default `true`) — set to `false` to disable the hourly `UpdateCheck.ts` request to `${updateServer}/info.json`.
Expand Down
17 changes: 17 additions & 0 deletions admin/src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ export const UpdateBanner = () => {
);
}

// Tier 4: tier is autonomous but the maintenance window isn't usable.
// Surface that before the generic "update available" banner so the admin
// knows the autonomous behavior is sitting idle.
const policyReason = updateStatus.policy?.reason;
if (updateStatus.tier === 'autonomous'
&& (policyReason === 'maintenance-window-missing'
|| policyReason === 'maintenance-window-invalid')) {
return (
<div className="update-banner update-banner-window" role="status">
<strong>
<Trans i18nKey={`update.banner.${policyReason}`}/>
</strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}

// Tier 3: scheduled update — show countdown banner instead of the plain
// "update available" one.
if (updateStatus.execution?.status === 'scheduled') {
Expand Down
48 changes: 48 additions & 0 deletions admin/src/pages/UpdatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,54 @@ export const UpdatePage = () => {
values={{tag: scheduled.targetTag, remaining: fmtRemaining(remainingMs)}}
/>
</p>
{/* Tier 4: only surface the deferral subtitle when `scheduledFor`
was actually snapped forward to the next window opening. The
backend keeps `scheduledFor = now + grace` whenever that lands
inside the window, so we can't use a fixed time-distance
heuristic (a normal 15-min grace would falsely match). Instead,
compare against `nextWindowOpensAt` with a small tolerance — the
two are computed seconds apart at request time, so an exact-ish
match is the only safe signal that the schedule was deferred. */}
{us.tier === 'autonomous' && us.nextWindowOpensAt
&& Math.abs(new Date(scheduled.scheduledFor).getTime()
- new Date(us.nextWindowOpensAt).getTime()) < 60 * 1000 && (
<p className="update-scheduled-deferred">
<Trans
i18nKey="update.page.scheduled.deferred_until"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</section>
)}

{us.tier === 'autonomous' && (
<section className="update-maintenance-window">
<h2><Trans i18nKey="update.window.title"/></h2>
{us.maintenanceWindow ? (
<>
<p>
<Trans
i18nKey="update.window.summary"
values={{
start: us.maintenanceWindow.start,
end: us.maintenanceWindow.end,
tz: us.maintenanceWindow.tz,
}}
/>
</p>
{us.nextWindowOpensAt && (
<p>
<Trans
i18nKey="update.window.next_opens_at"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</>
) : (
<p><Trans i18nKey="update.window.unset"/></p>
)}
</section>
)}

Expand Down
9 changes: 9 additions & 0 deletions admin/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export type LastResult = null | {
at: string;
};

export interface MaintenanceWindow {
start: string;
end: string;
tz: 'local' | 'utc';
}

export interface UpdateStatusPayload {
currentVersion: string;
latest: null | {
Expand All @@ -44,6 +50,9 @@ export interface UpdateStatusPayload {
execution: Execution;
lastResult: LastResult;
lockHeld: boolean;
// Tier 4 additions:
maintenanceWindow: MaintenanceWindow | null;
nextWindowOpensAt: string | null;
}

type ToastState = {
Expand Down
42 changes: 41 additions & 1 deletion doc/admin/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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)** — designed, not yet implemented.
- **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

Expand Down Expand Up @@ -192,3 +192,43 @@ The right way to give docker admins an in-product Apply button is to delegate to
- **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](https://containrrr.dev/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:

```jsonc
{
"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

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: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 `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.
Loading
Loading