Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
d7573d8
docs: A/B update engine spike write-up (#485)
onel Oct 1, 2026
03daf03
docs: address Greptile on the A/B spike write-up
onel Oct 1, 2026
0276201
Merge pull request #558 from onmoose/docs/485-ab-update-engine
onel Oct 1, 2026
ef861dc
docs: design the A/B OS update and split the version lines (#486)
onel Oct 1, 2026
2df7fee
docs: address review on the A/B OS design
onel Oct 1, 2026
8a85935
docs: no skipped minors for the OS, and Tier-2 keeps its own run state
onel Oct 1, 2026
8910ffd
docs: the OS part of a target lists one release per minor
onel Oct 1, 2026
91dce0a
docs: how the box picks an OS release from the target list
onel Oct 1, 2026
b48a7e8
docs: a minor is a MAJOR.MINOR line, ordered as a version
onel Oct 1, 2026
b90f181
Merge pull request #565 from onmoose/docs/486-ab-os-design
onel Oct 1, 2026
d4f888e
Stamp the brain from CONTROL_PLANE_VERSION, host-agent from VERSION
onel Oct 1, 2026
d00f67b
Cut OS and control-plane releases from their own version files
onel Oct 1, 2026
b9b4145
Document the two release lines as built
onel Oct 1, 2026
bd36d69
progress: record the cloud-image CI run for #559
onel Oct 1, 2026
d1bd4dc
fixup: resume a release whose tag already points at this commit
onel Oct 1, 2026
148141f
fixup: check the brain and the UI image tags, not only the brain
onel Oct 1, 2026
61cd442
fixup: publish one line on dispatch and never overwrite a published file
onel Oct 1, 2026
2a5b973
fixup: give the version-split decision one account, and document the …
onel Oct 1, 2026
f5dc8b4
fixup: record the post-review cloud-image CI run
onel Oct 1, 2026
a8363ed
fixup: attach the image and its checksum as one pair, and repair latest
onel Oct 1, 2026
808d315
fixup: record the cloud-image CI run after the pair and latest fixes
onel Oct 1, 2026
b5d8c80
fixup: only move latest forward
onel Oct 1, 2026
8b17b78
Merge pull request #567 from onmoose/feat/559-control-plane-version
onel Oct 1, 2026
b0abf3b
OS package lock: Debian snapshot, pinned Docker, daily bump (#560)
onel Oct 1, 2026
c66153f
docs: fix what the version split and the OS lock made stale
onel Oct 1, 2026
8b3bdbb
host-agent (hosted): report the state partition as the System volume
onel Oct 1, 2026
582b5e4
Hosted image in the A/B layout: slots, state partition, GRUB on both …
onel Oct 1, 2026
9000d06
Lean set: grub-efi, rauc, e2fsprogs in; systemd-boot out (#561)
onel Oct 1, 2026
2a85446
Slots: 1 GiB read-only squashfs-xz, a 128 MiB ESP, and a slot budget …
onel Oct 1, 2026
33e1d74
ESP: format the 128 MiB FAT32 with one sector per cluster (#561)
onel Oct 1, 2026
a9c9b70
ESP: build with 512-byte sectors so 128 MiB can be FAT32 (#561)
onel Oct 1, 2026
f00965c
state-setup: give systemd-repart a writable TMPDIR (#561)
onel Oct 1, 2026
21df8bc
Boot lane: check the slot's initramfs can find a virtio-SCSI disk (#561)
onel Oct 1, 2026
e7b689e
Docs: the hosted A/B layout as built, the disk budget, and the slot d…
onel Oct 1, 2026
5e003e3
run-cloud-tests: plain punctuation in the firmware comment (#561)
onel Oct 1, 2026
fab2d8e
Progress entry: record the last unseeded run (#561)
onel Oct 1, 2026
681ad9a
fixup: GRUB fallback clears only the known-good slot's try flag (#561)
onel Oct 1, 2026
fc4f473
fixup: find the state partition on the boot disk only (#561)
onel Oct 1, 2026
7f35a72
fixup: docs for the GRUB fallback rule and the boot-disk lookup (#561)
onel Oct 1, 2026
5e21891
fixup: read GPT names with blkid, not lsblk, in the initramfs (#561)
onel Oct 1, 2026
1a0ad2f
fixup: record the final full-list run 36941147838 (#561)
onel Oct 1, 2026
dd54e93
Merge pull request #570 from onmoose/feat/561-hosted-ab-layout
onel Oct 2, 2026
d10de95
OS lock (security): libpng16-16t64 1.6.48-1+deb13u5 to 1.6.48-1+deb13u6
github-actions[bot] Oct 2, 2026
5649248
ci: build the cloud image once, boot in parallel (#486) (#572)
onel Oct 2, 2026
eed8741
Merge pull request #571 from onmoose/bot/os-lock
onel Oct 2, 2026
04aafc1
Build, sign and publish a RAUC bundle per OS release (#562) (#573)
onel Oct 2, 2026
1387f71
host-agent applies OS updates: install to the other slot, switch in t…
onel Oct 2, 2026
e001b4e
Keep GRUB's try flag under UEFI: give the boot lane's OVMF a VARS sto…
onel Oct 2, 2026
c6889dc
OS lock: docker-compose-plugin 5.5.1-1~debian.13~trixie to 5.6.0-1~de…
github-actions[bot] Oct 3, 2026
9c6f022
Merge pull request #577 from onmoose/bot/os-lock
onel Oct 5, 2026
40938d9
os-update boot: poll os.state after a wrong digest, do not read it once
onel Oct 5, 2026
9fd67ba
Merge pull request #578 from onmoose/fix/os-update-state-poll
onel Oct 5, 2026
3a50440
OS releases bake the last released control plane (#566) (#579)
onel Oct 5, 2026
0b31a36
A/B OS image: a slot that hangs, and a Debian major across /etc (#486…
onel Oct 6, 2026
649a5ae
Commit the RAUC release root CA (public cert)
onel Oct 6, 2026
7e1c179
Merge pull request #581 from onmoose/release/rauc-root-ca
onel Oct 6, 2026
b7dcae3
Bump VERSION and CONTROL_PLANE_VERSION to 0.16.0
onel Oct 6, 2026
25a909d
Merge pull request #582 from onmoose/release/0.16.0-bump
onel Oct 6, 2026
398fa41
os update: a failed switch undo keeps the trial marker (#584)
onel Oct 6, 2026
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
38 changes: 38 additions & 0 deletions .github/actions/setup-mkosi/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Set up an ubuntu-24.04 runner to build a moose image with mkosi. Shared by
# ci-cloud-image.yml (the build, boot proofs and publish) and os-lock-bump.yml
# (the scheduled OS package lock bump, #560), so both build with the same mkosi
# and the same host tools, and a bump resolves exactly what CI will check.
name: Set up mkosi
description: Relax the userns restriction, install the image-build tools and mkosi v26.
runs:
using: composite
steps:
# mkosi 26 builds rootless: it unshares a user namespace and drops
# capabilities inside it. Ubuntu 24.04 ships
# kernel.apparmor_restrict_unprivileged_userns=1, which hands an unconfined
# process a userns with no CAP_SETPCAP, so mkosi's PR_CAPBSET_DROP returns
# EPERM and the build dies at sandbox bring-up (#189). Relaxing the knob is
# safe on an ephemeral runner.
- name: Relax AppArmor unprivileged-userns restriction
shell: bash
run: |
knob=/proc/sys/kernel/apparmor_restrict_unprivileged_userns
echo "before: $(cat "$knob" 2>/dev/null || echo 'n/a')"
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo "after: $(cat "$knob")"

# Host tooling mkosi needs to bootstrap a Debian tools tree and build
# rootless (uidmap/mmdebstrap/keyring; dosfstools/mtools for the ESP;
# squashfs-tools/e2fsprogs for the root; systemd-container for the nspawn
# build sandbox; libpam0g-dev for the slim host-agent-real CGO build).
- name: Install mkosi and image-build tooling
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y \
uidmap mmdebstrap debian-archive-keyring \
dosfstools mtools squashfs-tools e2fsprogs systemd-container \
libpam0g-dev
git clone --depth=1 --branch v26 \
https://github.com/systemd/mkosi.git /tmp/mkosi-src
sudo ln -sf /tmp/mkosi-src/bin/mkosi /usr/local/bin/mkosi
1,070 changes: 930 additions & 140 deletions .github/workflows/ci-cloud-image.yml

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion .github/workflows/ci-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ name: CI / Go

on:
pull_request:
branches: [dev, main]
# hotfix/** gates an OS lock bump into a patch-release branch (#560).
branches: [dev, main, "hotfix/**"]
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "Makefile"
- "VERSION"
- "api/**"
# The lock files have no .go in them, but dev/os-lock/oslock's test checks
# they fit together, so a lock bump PR must run it (#560).
- "dev/os-lock/**"
- ".github/workflows/ci-go.yml"

# Cancel superseded runs on the same ref (e.g. force-pushes to a PR branch).
Expand Down
475 changes: 475 additions & 0 deletions .github/workflows/os-lock-bump.yml

Large diffs are not rendered by default.

292 changes: 165 additions & 127 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Two phrases that constrain a lot of design:
A running moose is five processes/artifacts. Three are Go, one is JavaScript, one is a container we don't write.

- **`moose-brain`** (`cmd/brain/`, `internal/`) — the control-plane daemon. One Go binary: owns SQLite state, the REST+SSE API, the app lifecycle, and the Caddy config. Drives Docker via the `docker compose` CLI.
- **`host-agent`** — the privileged side. Two binaries. `cmd/host-agent/` is the **fake** used by the inner dev loop: it speaks the real `BRAIN_HOST_PROTOCOL.md` wire format over a real UNIX socket, but the host ops are stubbed in memory. `cmd/host-agent-real/` is the real one, and most of it works now: PAM verify, user management, `/proc` sampling, disk and RAM, journal streaming, service health, reboot-required, time zone, Avahi discovery, the first-boot brain launch, and the control-plane update path. Not wired yet: LUKS/TPM, apt, and NetworkManager config. It also builds a slim `hosted` variant (`go build -tags hosted`) for the cloud image. See `docs/architecture.md` # Components for the current split.
- **`host-agent`** — the privileged side. Two binaries. `cmd/host-agent/` is the **fake** used by the inner dev loop: it speaks the real `BRAIN_HOST_PROTOCOL.md` wire format over a real UNIX socket, but the host ops are stubbed in memory. `cmd/host-agent-real/` is the real one, and most of it works now: PAM verify, user management, `/proc` sampling, disk and RAM, journal streaming, service health, reboot-required, time zone, Avahi discovery, the first-boot brain launch, and the control-plane update path. Not wired yet: LUKS/TPM, NetworkManager config, and the A/B OS update (#486, designed in `UPDATES.md` # 1 and `BUILD.md` # 1b; there is no apt on the update path). It also builds a slim `hosted` variant (`go build -tags hosted`) for the cloud image. See `docs/architecture.md` # Components for the current split.
- **`web-ui`** (`web-ui/`) — Vue 3 + Vite + TanStack Query dashboard. Talks only to the brain.
- **Caddy** (`dev/`) — reverse proxy. Terminates `*.local` and routes to app containers + the brain, configured live by the brain via Caddy's admin API. Subdomain routing per app, never path-based (browser same-origin policy is the reason).
- **SQLite** — the brain's only persistent store (`internal/store/`).
Expand Down Expand Up @@ -61,7 +61,7 @@ The inner/outer boundary is also the **cross-platform / Linux-only** boundary. T
- `make test-nopam` — full suite minus the PAM package, for when you don't have `libpam0g-dev` (i.e. off Linux).
- `make clean` — stop dev Caddy, remove moose containers/networks, wipe `.dev/state`.

**Building the hosted cloud image is a CI job: don't build it locally.** `make build-cloud-image` / `make test-cloud-qemu` need root + `/dev/kvm` + mkosi and take ~10+ min; a local mkosi build is fragile and easy to get wrong (a broken build is what once produced a phantom "`:443` doesn't bind" hunt). Instead trigger the **`CI / Cloud image`** GitHub Action: `gh workflow run "CI / Cloud image" --ref <branch> -f publish=false` builds the image and runs the QEMU boot-proof (`unseeded seeded bios access update ssh remap` boots; add `-f boots="remap"` to run only some) **without** publishing anything. Only `publish=true` (the default) publishes anything. Since #352 that means two things: it **attaches the compressed image and its checksum to the tagged GitHub Release**, and it **pushes the brain and UI images to ghcr**. It uploads to no hosting provider and holds no provider login. Publishing is a deliberate act, not a test. See [`docs/dev/hosted-boot-proof.md`](docs/dev/hosted-boot-proof.md) for reading the result and debugging a red boot.
**Building the hosted cloud image is a CI job: don't build it locally.** `make build-cloud-image` / `make test-cloud-qemu` need root + `/dev/kvm` + mkosi and take ~10+ min; a local mkosi build is fragile and easy to get wrong (a broken build is what once produced a phantom "`:443` doesn't bind" hunt). Instead trigger the **`CI / Cloud image`** GitHub Action: `gh workflow run "CI / Cloud image" --ref <branch> -f publish=false` builds the image and runs the QEMU boot-proof (`unseeded seeded bios access update ssh remap` boots; add `-f boots="remap"` to run only some) **without** publishing anything. Only `publish=true` (the default) publishes anything. Since #352 that means two things: it **attaches the compressed image and its checksum to the tagged GitHub Release**, and it **pushes the brain and UI images to ghcr**. Since #559 those are two release lines (the OS and the control plane), and dispatch also takes `publish_os` and `publish_control_plane` to publish just one of them, for example `-f publish=false -f publish_control_plane=true` to re-publish only the control plane. A published file or version tag is never overwritten; only `latest` moves, and only forward (`BUILD.md` # 6). It uploads to no hosting provider and holds no provider login. Publishing is a deliberate act, not a test. See [`docs/dev/hosted-boot-proof.md`](docs/dev/hosted-boot-proof.md) for reading the result and debugging a red boot.

**Prerequisites for the inner loop:** Docker + `docker compose`, Node 20+, Go 1.23+, host port `:80` free (dev Caddy binds it so `<slug>.local` works portless), and `avahi-daemon` running on Linux (so `.local` names resolve under `make dev`). The full Go test suite additionally needs `libpam0g-dev` on Linux; see `docs/dev/running-locally.md`.

Expand Down Expand Up @@ -92,7 +92,7 @@ Small set of rules. Codified now so we don't have to back them out later.
- **Consumer-side interfaces.** Interfaces live in the package that *uses* them, not the package that implements them. `lifecycle.DockerDriver` lives in `internal/lifecycle/`, not in a hypothetical `internal/docker/`. Provider packages export concrete types only. Exception: a single interface shared by three or more consumers can move to the provider, but default to consumer-side until that's true.
- **Layer boundaries.** `internal/lifecycle` is the transaction owner; only `cmd/brain` and `internal/api` may import it. `internal/store` is the persistence boundary; only `internal/lifecycle`, `internal/api`, `internal/auth`, `internal/audit`, and `cmd/brain` may import it. Anything else reaching in is breaking the model — push the call through the right seam instead.
- **`log/slog` is the only logger.** No `"log"` imports, no `fmt.Println` for diagnostics. Structured fields, not interpolated strings: `slog.Info("app installed", "instance_id", id)`, not `slog.Info(fmt.Sprintf("installed %s", id))`. The default handler is set in `cmd/brain/main.go`; use `slog.Default()` (the package-level functions) — don't thread `*slog.Logger` through constructors.
- **Standard structured fields.** Use these key names so journalctl/jq filters stay stable: `instance_id`, `manifest_id`, `slug`, `service`, `image`, `host`, `upstream`, `step`, `err`, `output`, `user_id`, `username`, `role`, `action`, `actor_user_id`, `target_kind`, `target_id`, `retry_after`, `retry_after_s`, `name`, `uid`, `iface`, `interfaces`, `src`, `dir`, `profile`, `box_id`, `zone`, `exposure`, `trusted_proxies`, `brain`, `ui`, `minimum_host_agent`, `keys`, `state_dir`, `job_id`, `window`, `url`, `from`, `count`, `dropped`, `tier`, `remap_base`, `gid`, `image_user`. `host` is a machine or upstream hostname only — a single network interface name is `iface`, a list of them is `interfaces` (never overload `host` for either). `src` is a source filesystem path (bind-source, folder-source); `dir` is a relative bind dir path. `profile` is the resolved environment profile (`appliance`|`hosted`, `ENVIRONMENT.md`). `box_id` is the hosted box's provisioned identity (`ENVIRONMENT.md` # Provisioning). `zone` is an IANA time-zone name (`TIME.md`, host-agent set-timezone). `exposure` is an app's per-instance access mode (`restricted`|`public`, `ENVIRONMENT.md` #306). `trusted_proxies` is the configured set of proxies whose `X-Forwarded-For` the brain reads when deriving a client IP (`BRAIN_UI_PROTOCOL.md` # Rate limiting & abuse). `brain` and `ui` are the two control-plane versions a release names, and `minimum_host_agent` the host-agent version it requires (`RELEASE_MANIFEST.md`); use them for versions, not for image refs — an image ref is `image`. `keys` is how many signing keys a build accepts, and `state_dir` a state directory path (`src` stays for a source path being read or bound). `job_id` is a host-agent job id (`internal/hostagent/jobs.go`), and `window` the configured update window (`UPDATES.md` # 8.4). `url` is an HTTP endpoint the box reads, and `from` says which of several configured sources a setting came from, one of `answer` (the control plane's update-target answer), `seed`, `env`, `default` (`UPDATES.md` # 8.4); where there are only two sources, the boolean `from_<source>` form is used instead (`from_ledger`). `dropped` lists what the box read but could not use, from catalog data it reads leniently (AI provider entries, manifest `role` and `requires` keys), and `count` is how many there were. `tier` is an instance's user-namespace tier (`default`|`caps`|`image`|`host`, `APP_ISOLATION.md` # User-namespace tiers), and `remap_base` the first host id of the Docker remap range (0 for none). `image_user` is the user an image sets (its `Config.User`, a name or a number, as the image wrote it), which the image tier resolves to ids. `name` is an app instance's display name (it rides alongside `instance_id`, never instead of it), and `uid` a numeric Unix user id — the allocated app-service identity, a resolved home owner, or an image user's id. `gid` is its numeric group id. `retry_after` and `retry_after_s` come from the two throttles that `AUTH.md` # Rate limiting keeps apart on purpose. Both are correct. Do not merge them. `retry_after` is the login backoff's wait, written as a duration string (`internal/api/auth.go`); that path sends no `Retry-After` header, by design. `retry_after_s` is the general request limiter's wait, written as a whole number of seconds; it matches that limiter's `retry_after_s` JSON field and the `Retry-After` header it sets (`internal/api/ratelimit.go`, `BRAIN_UI_PROTOCOL.md` # 429 contract). To find every throttled request you must search for both keys. That is the price of the split, not a bug. Adding a new recurring field? Add it here.
- **Standard structured fields.** Use these key names so journalctl/jq filters stay stable: `instance_id`, `manifest_id`, `slug`, `service`, `image`, `host`, `upstream`, `step`, `err`, `output`, `user_id`, `username`, `role`, `action`, `actor_user_id`, `target_kind`, `target_id`, `retry_after`, `retry_after_s`, `name`, `uid`, `iface`, `interfaces`, `src`, `dir`, `profile`, `box_id`, `zone`, `exposure`, `trusted_proxies`, `brain`, `ui`, `minimum_host_agent`, `keys`, `state_dir`, `job_id`, `window`, `url`, `from`, `count`, `dropped`, `tier`, `remap_base`, `gid`, `image_user`, `os`, `slot`, `digest`. `host` is a machine or upstream hostname only — a single network interface name is `iface`, a list of them is `interfaces` (never overload `host` for either). `src` is a source filesystem path (bind-source, folder-source); `dir` is a relative bind dir path. `profile` is the resolved environment profile (`appliance`|`hosted`, `ENVIRONMENT.md`). `box_id` is the hosted box's provisioned identity (`ENVIRONMENT.md` # Provisioning). `zone` is an IANA time-zone name (`TIME.md`, host-agent set-timezone). `exposure` is an app's per-instance access mode (`restricted`|`public`, `ENVIRONMENT.md` #306). `trusted_proxies` is the configured set of proxies whose `X-Forwarded-For` the brain reads when deriving a client IP (`BRAIN_UI_PROTOCOL.md` # Rate limiting & abuse). `brain` and `ui` are the two control-plane versions a release names, and `minimum_host_agent` the host-agent version it requires (`RELEASE_MANIFEST.md`); use them for versions, not for image refs — an image ref is `image`. `keys` is how many signing keys a build accepts, and `state_dir` a state directory path (`src` stays for a source path being read or bound). `job_id` is a host-agent job id (`internal/hostagent/jobs.go`), and `window` the configured update window (`UPDATES.md` # 8.4). `url` is an HTTP endpoint the box reads, and `from` says which of several configured sources a setting came from, one of `answer` (the control plane's update-target answer), `seed`, `env`, `default` (`UPDATES.md` # 8.4); where there are only two sources, the boolean `from_<source>` form is used instead (`from_ledger`). `dropped` lists what the box read but could not use, from catalog data it reads leniently (AI provider entries, manifest `role` and `requires` keys), and `count` is how many there were. `tier` is an instance's user-namespace tier (`default`|`caps`|`image`|`host`, `APP_ISOLATION.md` # User-namespace tiers), and `remap_base` the first host id of the Docker remap range (0 for none). `image_user` is the user an image sets (its `Config.User`, a name or a number, as the image wrote it), which the image tier resolves to ids. `os` is an OS (moose) release version, as `brain` and `ui` are control-plane versions; `slot` is an A/B OS slot (`A`|`B`, `BUILD.md` # 1b); `digest` is an OS bundle's sha256 (`UPDATES.md` # 1). `name` is an app instance's display name (it rides alongside `instance_id`, never instead of it), and `uid` a numeric Unix user id — the allocated app-service identity, a resolved home owner, or an image user's id. `gid` is its numeric group id. `retry_after` and `retry_after_s` come from the two throttles that `AUTH.md` # Rate limiting keeps apart on purpose. Both are correct. Do not merge them. `retry_after` is the login backoff's wait, written as a duration string (`internal/api/auth.go`); that path sends no `Retry-After` header, by design. `retry_after_s` is the general request limiter's wait, written as a whole number of seconds; it matches that limiter's `retry_after_s` JSON field and the `Retry-After` header it sets (`internal/api/ratelimit.go`, `BRAIN_UI_PROTOCOL.md` # 429 contract). To find every throttled request you must search for both keys. That is the price of the split, not a bug. Adding a new recurring field? Add it here.
- **Typed errors at boundaries, not everywhere.** Define a sentinel/typed error only when a *consumer* needs to discriminate (HTTP status, retry decision, UI text). `store.ErrNotFound` exists because the API maps it to 404. Don't pre-declare error types speculatively.
- **No premature abstraction.** Don't introduce an interface, factory, or DI container until at least two concrete consumers exist. It bites hardest in Go where every extra interface is import-graph weight.
- **`internal/` for everything except `cmd/`.** No `pkg/`. Anything inside `internal/` is private to this module by Go's own rules — no public API surface to maintain.
Expand Down
1 change: 1 addition & 0 deletions CONTROL_PLANE_VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.16.0
Loading
Loading