Skip to content

Latest commit

 

History

History
218 lines (166 loc) · 11.6 KB

File metadata and controls

218 lines (166 loc) · 11.6 KB

Self-update in the Docker Compose deployment

Codeman running as a container updates itself from App Settings → Updates, the same place and the same button as a bare-host install. This document explains how that works, what it deliberately refuses to do, and how to recover when it stops.

The bare-host updater is documented in architecture-invariants.md#self-update; this file covers only what the container changes.

The short version

Change in the release Applied by
Application code The in-app updater
docker/server.Dockerfile docker/Start-Codeman.sh on the host
docker/docker-compose.yaml docker/Start-Codeman.sh on the host
New key in docker/.env.example Add it to docker/.env, then Start-Codeman.sh

The in-app updater detects all three of the bottom rows itself and refuses with a message naming what changed, so you never have to work out which case you are in.

Why the container needs its own path

The bare-host updater does git checkout <tag> && npm install && npm run build, then asks systemd or launchd to restart the service. Two of those assumptions are false in a container:

  1. There is no init system. A container's supervisor is the Docker daemon, which acts on the container, not on processes inside it.
  2. The image is immutable. A git pull into the image's baked /opt/codeman would land in the container's writable layer, survive docker restart, and be silently discarded by the next docker compose up.

Both are solved by configuration rather than by a second updater:

  • The checkout is a host bind mount. docker-compose.yaml mounts the repo (the same directory used as the build context) over /opt/codeman, so the updater's git checkout writes to the host filesystem and survives the container being recreated.
  • The restart is the server exiting. restart: unless-stopped relaunches the container whenever its main process ends, including on a clean exit — so the updater's final step is to signal the server, and Docker starts it again on the freshly built dist/.

Everything else — the release-tag channel, the auto-stash, the atomic update-status.json the browser polls across the connection drop, the boot-time reconcile that flips restarting to completed — is the existing machinery, unchanged. The container path is a new SupervisorKind, not a new updater.

What the pieces are

Piece Role
Repo bind mount at /opt/codeman Makes the pull persistent. Without it, self-update is unavailable.
codeman-node-modules, codeman-dist volumes Container-owned build artefacts, layered over the bind mount.
CODEMAN_IN_CONTAINER=1 Tells detectSupervisor() to restart by exiting.
restart: unless-stopped Turns that exit into a restart. Verified before every update.
CODEMAN_RESTART_BY_EXIT=1 The Compose file's declaration of that policy, so the updater may exit even with no Docker socket.
Toolchain + devDependencies in the image Lets npm install and npm run build run inside the container.
docker-env-applied.json Fingerprint baseline, written by Start-Codeman.sh on every start.

Why build artefacts are in named volumes

node_modules and dist are mounted as named volumes on top of the repo bind mount. Without that, an update's npm install would write into the host checkout, leaving container-compiled native modules (node-pty builds from source here) in a directory that may also be used to run Codeman natively, and leaving git status permanently noisy.

Docker seeds an empty named volume from the image, so the first start inherits the image's already-built node_modules and dist and pays no bootstrap cost. docker compose down -v is the supported reset: the next start re-seeds them.

Why the runtime image carries a build toolchain

npm run build is tsc plus esbuild, both devDependencies, so the image no longer runs npm prune --omit=dev. And npm install may rebuild node-pty, which ships no Linux prebuild, so python3, make and g++ are installed as well.

This is the real cost of in-place updates: a noticeably larger image than a runtime-only one. It buys an update that takes about a minute instead of a full image rebuild, and it is why NODE_ENV=production is paired with an explicit npm install --include=dev in the updater.

The environment gate

An in-place update applies code only. A restarted container reuses its existing image and configuration, so a release that changes the environment cannot take effect that way — and would half-apply: new code against an old environment. The updater therefore checks the target release's own files, read straight out of git with git show <tag>:<path> before anything is checked out.

1. server.Dockerfile changed, so the image must be rebuilt

Compared by sha256 against the fingerprint Start-Codeman.sh recorded when the running container was built.

2. docker-compose.yaml changed, so the container must be recreated

Same mechanism. A restart cannot pick up a new mount, port or environment variable; only recreating the container can.

3. .env.example gained keys your .env has no value for

The check that matters most, because Compose will not tell you. An unset ${VAR} interpolates to the empty string; Compose prints a warning to a terminal nobody is watching and starts anyway. A new required setting therefore arrives as a silently blank environment variable and misbehaves later, far from the cause. The updater names the missing keys instead.

Commented-out lines in .env.example are deliberately not keys — that is how the file marks optional overrides such as # PUID=1000, and counting them would block updates on settings you are meant to leave alone.

4. A restart policy that would not bring the container back

Before signalling the server, the updater asks the Docker daemon for its own container's restart policy. If it is no, the update is refused: applying it would take Codeman down and leave no UI to recover from.

If the policy cannot be read at all (no Docker socket mounted) the update is still allowed, but the final step changes: the server exits only when the Compose file declared CODEMAN_RESTART_BY_EXIT=1 (the shipped one does, because it is the file that sets restart: unless-stopped) or the daemon confirmed an auto-restart policy. Otherwise the build completes and the panel asks you to restart the container by hand. A container started by plain docker run with no restart policy therefore gets a staged update, never an outage.

What the gate deliberately does not do

Every unknown fails open:

  • A missing fingerprint baseline (a container started before this feature existed) is not treated as a change, or those installs could never update at all.
  • An unreadable .env, an unreachable Docker socket, or a target tag whose files cannot be read all yield "no blocker" rather than a refusal.

The one place an unknown does NOT fail open is the kill itself: with neither the Compose declaration nor a daemon answer, the updater stages the build and asks for a manual restart rather than exiting a server nothing may bring back.

The gate catches a specific, detectable class of mistake; it is not a last line of defence. It is also re-evaluated server-side on POST /api/system/update, so hiding the button in the UI is a courtesy rather than the control.

The one residual risk

The gate is derived from the diff, so it cannot see a release that needs a newer environment without changing any of those files — for example, code that depends on newer agent-CLI behaviour.

That is why the four global CLIs in server.Dockerfile are pinned. Unpinned, the versions a user ends up with are a function of when their image was built rather than of any commit, and in-app updates make rebuilds rarer, which makes that drift worse over time. Pinned, "this release needs a newer CLI" becomes a Dockerfile change, which check 1 already detects. Bump them deliberately, as part of a release.

The complementary merge-side guard is test/docker-compose-env-parity.test.ts, which fails CI when a variable is added to docker-compose.yaml without an entry in .env.example, or the reverse.

Sequence of an in-place update

  1. CheckGET /api/system/update/check finds the latest release tag, fetches that one ref so the gate can read the target's files, and returns any blockers.
  2. StartPOST /api/system/update re-evaluates the gate, writes queued to update-status.json, stages self-update.sh outside the repo and runs it.
  3. Apply — stash if dirty, fetch the tag, check it out, npm install --include=dev, npm run build. A failure at any step rolls back to the previous commit, rebuilds it and reports failed; the server is never restarted into a broken build.
  4. Restart — write the terminal restarting marker, then signal the server. The container exits and Docker restarts it.
  5. Reconcile — the rebooted server compares its own version against the target and flips the status to completed or failed. The browser, still polling, picks that up.

Step 4 kills the updater script along with the container — unlike the systemd path, it does not outlive the restart. That is safe only because the terminal marker is written first, which is why nothing may be appended after the kill.

Troubleshooting

"This install can't update itself (unknown)" — the repo bind mount is missing, so the container is running the baked image copy. Check CODEMAN_REPO_PATH and confirm the mounted directory really contains .git.

The update fails immediately with a git ownership or permission error — the mounted checkout belongs to a different user than the one Codeman runs as (PUID), so git refuses it as "dubious ownership". Start-Codeman.sh warns about this at start; fix it by chowning the checkout to the same account that owns CODEMAN_APPDATA_PATH.

A rebuild is reported as required every time — the fingerprint baseline does not match the checkout. Start-Codeman.sh writes it on every start, so start through that script rather than a bare docker compose up after either file changes.

Codeman does not come back after an update — the build succeeded, since the updater gates the restart on it, so read the container logs with docker compose logs codeman. To roll back, check out the previous tag in the host checkout and run docker/Start-Codeman.sh.

The update failed during npm install — most likely a native rebuild with no toolchain, meaning the image predates the toolchain being added. Rebuild once from the host and the in-app path works from then on.

Resetting the build artefactsdocker compose down -v, then Start-Codeman.sh. This discards the named volumes and re-seeds them from a fresh image.

Disabling it

Set CODEMAN_DISABLE_SELF_UPDATE=1 in docker/.env and pass it through in the compose file's environment: block. The Updates panel then reports that in-app updates are disabled, and the host-side script is the only way to update.