- TypeScript 97.2%
- Svelte 2.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Migrates the last module off the deprecated progressTopic / async-route pattern (core#21): the three apt operations become ctx.tasks.define actions behind a shared "apt" lock, the routes answer { runId }, and run outcome is a status rather than the wording of a log line.
Approved at
|
||
| backend | ||
| frontend | ||
| CLAUDE.md | ||
| opsdeck.module.json | ||
| README.md | ||
module_updates
OpsDeck module reporting and applying the host's pending package updates (Debian/Ubuntu). Schema-driven: every page renders natively on web and mobile from the portable UI schema. The only custom web code is the dashboard overview card, which the schema cannot express.
Pages
- Root — pending and security counts, reboot state with its age, kernel
drift, when apt last refreshed its lists, the last
unattended-upgradesrun and its result, host-access status, a 7-day chart, the admin actions and the live log of the current or last run. - Packages (
packages) — every pending package: name, installed version, candidate, security flag and origin, with security rows dotted amber.
Actions
Admin-only, confirmed, and streamed into the log block as they run:
| Action | Command on the host |
|---|---|
| Refresh package lists | apt-get update |
| Install security updates | apt-get -y … install --only-upgrade <security packages> |
| Install all updates | apt-get -y … dist-upgrade |
Each is a host-owned task run: the route starts it and answers
{ runId }, and clients follow core:task:<runId> — so the run shows up in
Activity with who asked for it, survives a page reload, and is still a row
after a restart that killed it (frozen interrupted, never retried; core
cannot know how far dpkg got).
One apt process at a time, enforced by a shared apt lock rather than a
boolean — and the two ways of asking twice get different answers:
- The same action while one of its runs is live is refused: 409, and a
skippedrow. Asking twice for a refresh is a double click. - A different action is queued behind the lock: 200 with
{ queued: true }and awaitingrow that starts when the lock frees. dpkg takes one lock on the host anyway, so the alternative to a queue is a refusal followed by the operator retrying by hand. A security upgrade queued behind a refresh also re-simulates against the lists that refresh just updated.
The buttons therefore stay offered while a run is live — they are gated on host access alone, and each confirmation says the run may queue. Hiding them whenever anything was live or waiting would leave the queue with no user that has anything but a shell.
A failed run raises one admin notification carrying apt's exit line. A refresh
counts as failed when apt could not fetch a source, not only when it exits
non-zero — apt-get update exits 0 on a dead mirror or broken DNS and
quietly keeps the old lists, which would report "package lists refreshed" over
lists nothing refreshed.
Non-admins see that a run happened and how it ended, but never its output. Not
because package names are secret — /packages serves the whole pending table
to any viewer, which is the module's point — but because a run's log carries
what that table does not: the $ apt-get … command lines (how this module
reaches the host), dpkg maintainer-script and conffile diagnostics,
configuration paths, and full repository URLs, credentials and all on a
private mirror. So all four ways out are closed together: the run's line tail
(core's projection — expose names result and reasonData, never lines;
reasonData is what lets the failure notification carry apt's exit line, and
is itself always null here), GET /run, the runlog live topic and the
deprecated progress topic.
--only-upgrade is deliberate:
it refuses to install a package that is not installed, so a new kernel ABI
package arrives the way apt intends — as a dependency of linux-image-generic
— rather than this module deciding to install new packages on someone's host.
Packages in that situation are named in the log and left to "Install all
updates".
There is no reboot action, by design.
Where the data comes from
apt-get -s -o Debug::NoLocking=true dist-upgrade # `Inst ` lines = pending
<hostRoot>/run/reboot-required[.pkgs] # presence = reboot pending
<hostRoot>/boot/vmlinuz-* # installed kernels
<hostProc>/version # running kernel
<hostRoot>/var/log/unattended-upgrades/unattended-upgrades.log
<hostRoot>/var/lib/apt/periodic/update-success-stamp
Only the package list needs a host command; everything else is a plain read through the host-root bind mount and keeps working when command execution does not. Three things are worth knowing before changing any of it:
/usr/lib/update-notifier/apt-checkis not installed on a minimal server. Depending on it means reporting "0 pending" on exactly the hosts that need watching — hence parsingapt-get -s./var/runis an absolute symlink to/run, and Deno resolves that against the container's root, not the bind mount.<hostRoot>/var/run/…silently asks about the container; read<hostRoot>/run/….- Kernel drift is independent of the reboot flag. A host can run
6.8.0-134 with 6.8.0-136 installed and no
/var/run/reboot-requiredin sight, so drift is detected by comparing the running release against the newest installed one of the same flavour — and reported as its own finding.
How it reaches the host
The container has its own mount namespace, so apt-get run plainly would
report on — and upgrade — the container. backend/host.ts probes chroot →
nsenter (ns files) → nsenter -t 1 → direct and keeps the first that works.
The functional test is stricter here than in the other modules, and it has to
be: the OpsDeck runtime image is debian:bookworm-slim, so command -v apt-get succeeds inside the container too. "direct" would then look healthy
right up until someone clicked "Install all updates". So the probe asks for
identity instead of availability — the inode of / as the strategy sees it
must equal the inode of the host-root bind mount as we see it. A bind mount
shares the superblock, so the host's / keeps its inode through it; the
container's overlay root does not. This also catches nsenter -t 1 inside a
container, which enters its own PID 1 namespaces and "succeeds" as a no-op.
Without a working strategy the module degrades to read-only and the root page says why, instead of failing at the click.
Alerts
Notifications are for operational events. "56 packages pending" is a state: it
lives on the page, and it is never notified. Everything below is
edge-triggered — notify with a key updates an open notification, but a
dismissed one is gone, so a level-triggered check would recreate it forever
and dismissal would never stick.
- New security updates — compared by package name, not by count, so 3 → 3 with a different package in it alerts and 7 → 3 after a partial upgrade does not.
- Reboot pending — one warning at
REBOOT_ALERT_DAYS, one escalation to error at twice that, then silence. - unattended-upgrades failed — keyed on the run that failed, so later ticks reading the same log never re-raise it.
The first tick after a restart only establishes the baseline: state that already existed is on the page, and re-announcing it on every restart is how a dismissal gets undone.
Metrics
updates.pending, updates.security and reboot.daysPending, sampled every
5 minutes.
Config
OPSDECK_MOD_UPDATES_REBOOT_ALERT_DAYS— days a pending reboot may sit before it is alerted (default 7, clamped 1–365).
Dev loop
cp -r . <core>/packages/modules/updates
cd <core>
deno check packages/modules/updates/backend/mod.ts
deno test --allow-read packages/modules/updates/backend/apt_test.ts
OPSDECK_AUTH=disabled OPSDECK_MODULES=updates deno task start
# http://localhost:8080/m/updates — schema at /api/core/modules/updates/ui
apt_test.ts covers the parsers against captured Ubuntu 24.04 output; that is
the part that will drift silently. Without a Debian host, point
OPSDECK_HOST_ROOT at a directory tree holding the files above to exercise
the read paths. Remove the copy afterwards; this repo is the source of truth.
Production loading: OPSDECK_EXTERNAL_MODULES=<git-url>#<sha> on the host.
Needs the / → /host/root bind mount. No extra capabilities.