No description
  • TypeScript 97.2%
  • Svelte 2.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Julian Imhof eb70f7b196 feat: hand the update runs to core (#1)
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 7703979 after five review rounds. The two non-blocking follow-ups from the approving review — pinning the simulation's locale in state.ts, and correcting the Error-Mode=any rationale in apt.ts — are tracked in #2.
2026-08-20 10:51:32 +02:00
backend fix: the refresh that reported ok over lists nothing fetched 2026-08-19 09:45:51 +02:00
frontend fix: drop the lock guard measurement refuted, and the one-tap retry 2026-08-18 00:20:03 +02:00
CLAUDE.md fix: the refresh that reported ok over lists nothing fetched 2026-08-19 09:45:51 +02:00
opsdeck.module.json feat: dashboard overview card 2026-07-27 12:06:20 +02:00
README.md fix: the refresh that reported ok over lists nothing fetched 2026-08-19 09:45:51 +02:00

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-upgrades run 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 skipped row. Asking twice for a refresh is a double click.
  • A different action is queued behind the lock: 200 with { queued: true } and a waiting row 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-check is not installed on a minimal server. Depending on it means reporting "0 pending" on exactly the hosts that need watching — hence parsing apt-get -s.
  • /var/run is 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-required in 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 chrootnsenter (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 1365).

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.