mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(docker): restore in-app self-update in the Compose deployment
Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.
Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:
- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
container on the new dist/. This is the one supervisor whose updater does NOT
outlive the restart, which is safe only because the terminal "restarting"
marker is written first.
- node_modules and dist are named volumes over the bind mount, so
container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
`npm run build` is tsc + esbuild and node-pty has no Linux prebuild.
An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.
The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.
Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.
Documented in docs/docker-self-update.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Restore in-app self-update for the Docker Compose deployment.
|
||||
|
||||
App Settings → Updates now works in the container, using the same updater,
|
||||
status file and progress UI as a bare-host install. The Compose file mounts the
|
||||
checkout it builds from at `/opt/codeman`, so an update's `git checkout` and
|
||||
rebuild land on the host and survive container recreation, and the restart is
|
||||
the server exiting — `restart: unless-stopped` relaunches it on the new build.
|
||||
|
||||
An in-place container update applies application code only, since a restarted
|
||||
container reuses its existing image and configuration. The updater therefore
|
||||
refuses a release that changes `docker/server.Dockerfile` or
|
||||
`docker/docker-compose.yaml`, or that adds keys to `docker/.env.example` the
|
||||
user's `.env` has no value for, naming what changed and pointing at
|
||||
`docker/Start-Codeman.sh` on the host. It also refuses when the container's
|
||||
restart policy would not bring it back. The missing-key check matters most:
|
||||
Compose resolves an unset `${VAR}` to the empty string and starts anyway, so a
|
||||
new required setting would otherwise arrive as a silently blank variable.
|
||||
|
||||
Supporting changes:
|
||||
|
||||
- The runtime image keeps devDependencies and gains `python3`/`make`/`g++`, so
|
||||
`npm install` and `npm run build` can run inside the container. This makes the
|
||||
image larger; that is the cost of updating in place.
|
||||
- Build artefacts live in `codeman-node-modules` and `codeman-dist` named
|
||||
volumes so container-compiled native modules never land in the host checkout.
|
||||
- The four global agent CLIs are pinned, so a release needing newer CLI
|
||||
behaviour becomes a Dockerfile change the environment gate can detect.
|
||||
- `docker/Start-Codeman.sh` records the Dockerfile and compose fingerprints the
|
||||
container was created from, which is the baseline the gate compares against.
|
||||
- New CI guard: `test/docker-compose-env-parity.test.ts` fails when a compose
|
||||
variable has no `.env.example` entry, or the reverse.
|
||||
|
||||
Documented in `docs/docker-self-update.md`.
|
||||
@@ -20,6 +20,13 @@ CODEMAN_RUNTIME_USER=opencode
|
||||
# directory in the container.
|
||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
|
||||
|
||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
|
||||
# start script detects it from the compose file's own location, so it only needs
|
||||
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
|
||||
# at a directory that is not a git checkout and in-app updates are unavailable.
|
||||
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
|
||||
|
||||
# Required for Docker cases. This must be an absolute path on the Docker host.
|
||||
# Codeman and each isolated case use this same path, so it cannot be a
|
||||
# container-only path such as /home/opencode/codeman-cases.
|
||||
|
||||
@@ -26,6 +26,18 @@ Codeman, Claude, OpenCode, and other local sessions run as the unprivileged acco
|
||||
|
||||
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
|
||||
|
||||
## Updating
|
||||
|
||||
Use **App Settings → Updates** in the web UI. The checkout Compose builds from is
|
||||
also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist
|
||||
on the host, and the server exiting is what restarts the container onto the new
|
||||
build.
|
||||
|
||||
Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
|
||||
`.env.example` cannot be applied that way — the updater detects them, names what
|
||||
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
|
||||
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
|
||||
|
||||
## Application data storage
|
||||
|
||||
The default configuration uses a host-folder bind mount:
|
||||
|
||||
@@ -70,4 +70,54 @@ fi
|
||||
|
||||
export DOCKER_SOCKET_GID=${socket_ids##*:}
|
||||
|
||||
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
|
||||
if [[ ! -d "$repo_path" ]]; then
|
||||
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
export CODEMAN_REPO_PATH="$repo_path"
|
||||
|
||||
# The in-app updater runs `git checkout` and `npm install` against this checkout
|
||||
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
|
||||
# ("detected dubious ownership") and the update fails at the first step — so warn
|
||||
# here, where the fix is obvious, rather than in a failed update hours later.
|
||||
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
|
||||
if [[ "$repo_owner" != "$PUID" ]]; then
|
||||
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
|
||||
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -d "$repo_path/.git" ]]; then
|
||||
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
|
||||
fi
|
||||
|
||||
# Record what the container is about to be built and created FROM. The in-app
|
||||
# updater compares these against the release it wants to apply: a release that
|
||||
# changes either file cannot be applied by the container restarting itself (a
|
||||
# restart reuses the existing image and config), so it is refused and the user
|
||||
# is sent back here. Written on every start, so the baseline always describes
|
||||
# the container that is actually running. See docs/docker-self-update.md.
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
|
||||
else
|
||||
sha256_of() { printf ''; }
|
||||
fi
|
||||
|
||||
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
|
||||
compose_sha=$(sha256_of "$compose_file")
|
||||
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
|
||||
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
|
||||
# dataPath('docker-env-applied.json') as the server inside the container sees it.
|
||||
state_dir="$appdata_path/.codeman"
|
||||
mkdir -p -- "$state_dir"
|
||||
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
|
||||
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
|
||||
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
|
||||
else
|
||||
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
|
||||
fi
|
||||
|
||||
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
|
||||
|
||||
@@ -15,6 +15,11 @@ services:
|
||||
ports:
|
||||
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
|
||||
environment:
|
||||
# Tells the self-updater to restart by exiting (the restart policy below
|
||||
# relaunches it) rather than by looking for an init system that is not
|
||||
# here. Also set in the image; repeated so a container started without the
|
||||
# image default still self-identifies.
|
||||
CODEMAN_IN_CONTAINER: "1"
|
||||
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
|
||||
# Host-side equivalent of the runtime user's HOME. Docker case seed,
|
||||
# credential and hook mounts are translated into the daemon namespace.
|
||||
@@ -48,6 +53,32 @@ services:
|
||||
- type: bind
|
||||
source: ${DOCKER_SOCKET}
|
||||
target: /var/run/docker.sock
|
||||
# The application source, so App Settings -> Updates can update in place.
|
||||
# This is the SAME checkout used as the build context above, mounted over
|
||||
# the image's baked copy: a `git checkout` performed inside the container
|
||||
# then lands on the host and survives the container being recreated.
|
||||
# Without it the pull would go to the container's writable layer and be
|
||||
# silently discarded by the next `up`. See docs/docker-self-update.md.
|
||||
# Defaults to `..` — the build context above — which Compose resolves
|
||||
# against the project directory, so plain `docker compose up` works with
|
||||
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
|
||||
- type: bind
|
||||
source: ${CODEMAN_REPO_PATH:-..}
|
||||
target: /opt/codeman
|
||||
# Build artefacts live in named volumes layered OVER the repo bind mount,
|
||||
# so `npm install` and `npm run build` inside the container never write
|
||||
# into the host checkout. That keeps container-compiled native modules
|
||||
# (node-pty is built from source here) out of a checkout that may also be
|
||||
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
|
||||
# an EMPTY named volume from the image, so the first start inherits the
|
||||
# image's already-built node_modules and dist rather than paying for a
|
||||
# bootstrap build.
|
||||
- type: volume
|
||||
source: codeman-node-modules
|
||||
target: /opt/codeman/node_modules
|
||||
- type: volume
|
||||
source: codeman-dist
|
||||
target: /opt/codeman/dist
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
security_opt:
|
||||
@@ -63,3 +94,12 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
# Container-owned build artefacts. They persist across container recreation,
|
||||
# so an in-app update's `npm install` output is not thrown away by the next
|
||||
# `up`, and they are seeded from the image on first use. Removing them (or
|
||||
# `docker compose down -v`) is the supported reset: the next start rebuilds
|
||||
# from the image.
|
||||
codeman-node-modules:
|
||||
codeman-dist:
|
||||
|
||||
@@ -12,9 +12,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY . .
|
||||
|
||||
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
|
||||
# updater rebuilds from inside this container, and `npm run build` is tsc +
|
||||
# esbuild — both devDependencies. Pruning them saves image size and takes the
|
||||
# self-updater with it. See docs/docker-self-update.md.
|
||||
RUN npm ci \
|
||||
&& npm run build \
|
||||
&& npm prune --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# The Docker CLI talks to the host daemon through the socket mounted by
|
||||
@@ -25,13 +28,21 @@ ARG CODEMAN_RUNTIME_USER=opencode
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
|
||||
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
|
||||
# runs `npm install` inside the running container, and node-pty ships no Linux
|
||||
# prebuild, so a release that bumps it compiles from source right here. Without
|
||||
# a toolchain that install fails and the update rolls back — every time, on the
|
||||
# releases that need it most. Same reason install.sh installs one on bare hosts.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssh-client \
|
||||
procps \
|
||||
python3 \
|
||||
ripgrep \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -59,11 +70,23 @@ COPY --from=docker:29-cli \
|
||||
|
||||
# Keep credentials out of the image. Users authenticate these CLIs at runtime
|
||||
# through Codeman sessions, and the configured host bind mount retains state.
|
||||
#
|
||||
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
|
||||
# a function of WHEN their image was built, not of any commit — so a Codeman
|
||||
# release that depends on newer CLI behaviour (the trust-dialog handling is
|
||||
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
|
||||
# on an older image with no diff anywhere to explain why. In-app updates make
|
||||
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
|
||||
# a newer CLI" into a Dockerfile change, which the updater's environment gate
|
||||
# already detects and refuses (docs/docker-self-update.md).
|
||||
#
|
||||
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
|
||||
# this layer when only the pins change upstream.
|
||||
RUN npm install --global \
|
||||
@anthropic-ai/claude-code \
|
||||
@google/gemini-cli \
|
||||
@openai/codex \
|
||||
opencode-ai \
|
||||
@anthropic-ai/claude-code@2.1.258 \
|
||||
@google/gemini-cli@0.58.0 \
|
||||
@openai/codex@0.152.1 \
|
||||
opencode-ai@1.18.26 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Keep the web server and every local Codeman session unprivileged. PUID and
|
||||
@@ -103,7 +126,12 @@ WORKDIR /opt/codeman
|
||||
|
||||
COPY --from=build /opt/codeman /opt/codeman
|
||||
|
||||
ENV CODEMAN_PORT=3000 \
|
||||
# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
|
||||
# than by asking an init system that is not here (src/web/self-update.ts).
|
||||
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
|
||||
# explicitly, since that value would otherwise omit the build toolchain.
|
||||
ENV CODEMAN_IN_CONTAINER=1 \
|
||||
CODEMAN_PORT=3000 \
|
||||
HOME=/home/${CODEMAN_RUNTIME_USER} \
|
||||
NODE_ENV=production
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIM
|
||||
|
||||
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
|
||||
|
||||
## Updating
|
||||
|
||||
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
|
||||
|
||||
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
|
||||
|
||||
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
|
||||
|
||||
Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md).
|
||||
|
||||
## Docker cases
|
||||
|
||||
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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`](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. |
|
||||
| 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.
|
||||
|
||||
### 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 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. **Check** — `GET /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. **Start** — `POST /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 artefacts** — `docker 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.
|
||||
+39
-6
@@ -7,16 +7,22 @@
|
||||
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
|
||||
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
|
||||
#
|
||||
# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the
|
||||
# restart IS the container exiting, which kills this script too. That is safe
|
||||
# because the terminal "restarting" marker is written before the kill and the
|
||||
# rebooted server reconciles it — but nothing may be added after that kill.
|
||||
#
|
||||
# Reports progress by writing ~/.codeman/update-status.json atomically; the
|
||||
# browser polls GET /api/system/update/status across the restart drop. The
|
||||
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
|
||||
#
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
|
||||
# manual command (foreground installs). Linux launches inside a transient
|
||||
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
# Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit
|
||||
# under Docker Compose (the restart policy relaunches it), or prints a manual
|
||||
# command (foreground installs). Linux launches inside a transient systemd scope
|
||||
# so `systemctl restart codeman-web` can't kill it mid-build.
|
||||
#
|
||||
# Args (all from the server, never user input — tag is validated server-side):
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
|
||||
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|docker-compose|none>
|
||||
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
|
||||
# --log <path> [--prev-sha <sha>] [--stash]
|
||||
#
|
||||
@@ -144,7 +150,7 @@ rollback_and_fail() {
|
||||
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
|
||||
if [[ -n "$PREV_SHA" ]]; then
|
||||
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit >/dev/null 2>&1 || true
|
||||
npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true
|
||||
npm run build >/dev/null 2>&1 || true
|
||||
fi
|
||||
fail "$msg — rolled back to the previous version" "$msg"
|
||||
@@ -176,7 +182,9 @@ write_status "checkout" "Checking out $TAG…"
|
||||
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
|
||||
|
||||
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
|
||||
# --include=dev: tsc and esbuild are devDependencies, and the Compose image sets
|
||||
# NODE_ENV=production, which would otherwise omit them and fail the build below.
|
||||
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \
|
||||
|| rollback_and_fail "Dependency install failed"
|
||||
|
||||
# 5) Build (gate the restart on success — never restart into a torn dist/).
|
||||
@@ -200,6 +208,31 @@ case "$SUPERVISOR" in
|
||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
||||
}
|
||||
;;
|
||||
docker-compose)
|
||||
# In the Compose deployment there is no init system to ask: the "restart" is
|
||||
# the server EXITING, so the container's `restart: unless-stopped` policy
|
||||
# relaunches it on the dist/ we just built. The repo and dist/ live on host
|
||||
# mounts, so the new build survives the container being replaced.
|
||||
#
|
||||
# ⚠️ This script dies WITH the container it is restarting — it is a child of
|
||||
# the server process, not a survivor like the systemd-scope path. That is
|
||||
# fine, and load-bearing: the terminal "restarting" marker is already written
|
||||
# above, and the freshly-booted server reconciles it. Nothing may be appended
|
||||
# after the kill that the update depends on.
|
||||
#
|
||||
# ⚠️ The server is signalled by PID rather than `docker restart`: this
|
||||
# container's own Docker CLI talks to the HOST daemon, and a self-directed
|
||||
# restart there races the client's own death. Exiting is the one path that
|
||||
# needs no cooperation from anything outside the container.
|
||||
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
|
||||
: # container exit + restart policy take it from here
|
||||
else
|
||||
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
|
||||
write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION."
|
||||
echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
launchd-daemon)
|
||||
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
|
||||
# domain needs root, but we don't need it — kill the server and launchd
|
||||
|
||||
+54
-3
@@ -7,6 +7,10 @@
|
||||
* (see `dataPath('update-status.json')`) that the browser polls across the
|
||||
* restart boundary.
|
||||
*
|
||||
* The Docker Compose deployment updates in place too (same script, same status
|
||||
* file) — see `docs/docker-self-update.md` for how the container restarts itself
|
||||
* and what the environment gate refuses.
|
||||
*
|
||||
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
|
||||
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
|
||||
*
|
||||
@@ -17,11 +21,56 @@
|
||||
* Which init system supervises the running server (decides how we restart it).
|
||||
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
|
||||
* login): restart works by killing the server and letting launchd respawn it.
|
||||
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
|
||||
* "restart" is the server exiting so the container's `restart: unless-stopped`
|
||||
* policy relaunches it on the freshly built `dist/`.
|
||||
*/
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
|
||||
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
|
||||
|
||||
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
||||
/**
|
||||
* How Codeman was installed. `git` and `docker-compose` can self-update in
|
||||
* place; `docker-compose` is a git checkout bind-mounted into the container, so
|
||||
* the pull/build happen on the host filesystem and survive container recreation.
|
||||
*/
|
||||
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
|
||||
|
||||
/**
|
||||
* Why an in-place container update is refused. Each is derived mechanically from
|
||||
* the target release's own files — nothing here depends on a human remembering
|
||||
* to declare something at release time.
|
||||
*
|
||||
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
|
||||
* which a self-restart cannot apply (a restart reuses the existing container's
|
||||
* image and config). Needs a rebuild + recreate from the host.
|
||||
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
|
||||
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
|
||||
* EMPTY STRING and starts anyway, so without this check a new required setting
|
||||
* arrives as a silently blank env var.
|
||||
* - `no-auto-restart`: the container's restart policy would not bring it back
|
||||
* after the server exits, so applying the update would take Codeman down.
|
||||
*/
|
||||
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
|
||||
|
||||
/** One reason an in-place container update is refused, with UI-ready text. */
|
||||
export interface EnvironmentBlocker {
|
||||
kind: EnvironmentBlockerKind;
|
||||
/** One-line explanation shown in App Settings → Updates. */
|
||||
message: string;
|
||||
/** Optional specifics (e.g. the names of the missing env keys). */
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the environment gate for a candidate release. `checked: false` means
|
||||
* the gate did not run (not a container install, or the target tag's files could
|
||||
* not be read) — callers must not treat that as "no blockers".
|
||||
*/
|
||||
export interface EnvironmentGate {
|
||||
checked: boolean;
|
||||
blockers: EnvironmentBlocker[];
|
||||
/** The host command that resolves every blocker. */
|
||||
hostCommand: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
|
||||
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
/** Environment gate for THIS candidate release (container installs only). */
|
||||
environment?: EnvironmentGate;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const verEl = this.$('updateCurrentVersion');
|
||||
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
|
||||
|
||||
if (data.installKind && data.installKind !== 'git') {
|
||||
this._setUpdateResult(
|
||||
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
|
||||
);
|
||||
// `docker-compose` self-updates in place like `git` does — the container
|
||||
// restarts itself. Anything else cannot.
|
||||
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
|
||||
const hint =
|
||||
data.supervisor === 'docker-compose'
|
||||
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
|
||||
: 'Update with <code>npm i -g aicodeman@latest</code>.';
|
||||
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
|
||||
return;
|
||||
}
|
||||
if (data.selfUpdateEnabled === false) {
|
||||
@@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setUpdateResult(escapeHtml(data.error));
|
||||
return;
|
||||
}
|
||||
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
|
||||
// or new .env keys) cannot be applied by the container restarting itself, so
|
||||
// the update button is never offered — the host command is, instead. The
|
||||
// server re-checks this on POST, so hiding the button is UX, not the gate.
|
||||
const blockers = data.environment?.blockers || [];
|
||||
if (data.updateAvailable && blockers.length > 0) {
|
||||
const reasons = blockers
|
||||
.map((b) => {
|
||||
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
|
||||
return `<li>${escapeHtml(b.message)}${details}</li>`;
|
||||
})
|
||||
.join('');
|
||||
this._setUpdateResult(
|
||||
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
|
||||
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
|
||||
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
|
||||
);
|
||||
if (notes && data.notes) {
|
||||
notes.style.display = 'block';
|
||||
notes.textContent = data.notes;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.updateAvailable && data.latestVersion) {
|
||||
this._setUpdateResult(
|
||||
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> (current v${escapeHtml(data.currentVersion || '')})`
|
||||
|
||||
@@ -389,6 +389,9 @@ export function registerSystemRoutes(
|
||||
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
|
||||
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
// A container release that changes the ENVIRONMENT: not a client error to
|
||||
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
|
||||
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
|
||||
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
|
||||
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
|
||||
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
|
||||
|
||||
+299
-12
@@ -16,6 +16,15 @@
|
||||
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
|
||||
* `reconcileUpdateOnBoot`) that touch git/network/fs.
|
||||
*
|
||||
* DOCKER COMPOSE installs update in place too, through the same script and the
|
||||
* same status file. The repo is a host bind mount, so the pull/build land on the
|
||||
* host filesystem and survive container recreation; the "restart" is the server
|
||||
* EXITING so the container's restart policy relaunches it on the new `dist/`.
|
||||
* That applies CODE only — a restart reuses the existing container's image and
|
||||
* config — so `evaluateEnvironmentGate()` refuses a release that changes
|
||||
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
|
||||
* host command instead. See `docs/docker-self-update.md`.
|
||||
*
|
||||
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
|
||||
* `src/web/routes/system-routes.ts`.
|
||||
*
|
||||
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, tmpdir } from 'node:os';
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import type {
|
||||
EnvironmentBlocker,
|
||||
EnvironmentGate,
|
||||
InstallInfo,
|
||||
InstallKind,
|
||||
SupervisorKind,
|
||||
@@ -215,6 +226,120 @@ export function reconcileStatusDecision(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers — the container environment gate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Host command that resolves every environment blocker. */
|
||||
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
|
||||
|
||||
/**
|
||||
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
|
||||
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
|
||||
* override, so treating those as required would block every update on settings
|
||||
* the user is meant to leave alone.
|
||||
*/
|
||||
export function parseEnvKeys(text: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && !keys.includes(m[1])) keys.push(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
|
||||
*
|
||||
* This is the check that makes a new required setting visible: Compose resolves
|
||||
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
|
||||
* otherwise silent until something misbehaves at runtime.
|
||||
*/
|
||||
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
|
||||
const have = new Set(parseEnvKeys(userEnv));
|
||||
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the container's restart policy relaunches it after the server exits.
|
||||
* `no` and an empty policy mean an in-place update would take Codeman DOWN
|
||||
* rather than restart it, so the update is refused instead.
|
||||
*/
|
||||
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
|
||||
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
|
||||
}
|
||||
|
||||
export interface EnvironmentGateInput {
|
||||
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
|
||||
appliedDockerfileHash: string | null;
|
||||
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
|
||||
targetDockerfileHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
|
||||
appliedComposeHash: string | null;
|
||||
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
|
||||
targetComposeHash: string | null;
|
||||
/** Keys from `diffRequiredEnvKeys()`. */
|
||||
missingEnvKeys: string[];
|
||||
/** Docker restart policy name of the running container, or null if unknown. */
|
||||
restartPolicy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE gate decision. An in-place container update applies CODE only: the server
|
||||
* exits and the container's restart policy relaunches it on the new `dist/`. A
|
||||
* restart reuses the existing container's image and config, so anything that
|
||||
* changes the ENVIRONMENT cannot take effect that way and is refused here with
|
||||
* the host command that can apply it.
|
||||
*
|
||||
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
|
||||
* container created before the fingerprint file existed has no baseline, and
|
||||
* failing closed there would block every such install from ever updating. The
|
||||
* baseline is written by `Start-Codeman.sh`, so it exists from the first
|
||||
* host-side start onward. An unknown restart policy is likewise not a blocker —
|
||||
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
|
||||
* socket, which a user may not have mounted.
|
||||
*/
|
||||
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
|
||||
const blockers: EnvironmentBlocker[] = [];
|
||||
|
||||
if (
|
||||
input.appliedDockerfileHash &&
|
||||
input.targetDockerfileHash &&
|
||||
input.appliedDockerfileHash !== input.targetDockerfileHash
|
||||
) {
|
||||
blockers.push({
|
||||
kind: 'dockerfile-changed',
|
||||
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
|
||||
blockers.push({
|
||||
kind: 'compose-changed',
|
||||
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.missingEnvKeys.length > 0) {
|
||||
blockers.push({
|
||||
kind: 'env-keys-missing',
|
||||
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
|
||||
details: input.missingEnvKeys,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
|
||||
blockers.push({
|
||||
kind: 'no-auto-restart',
|
||||
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Status file IO
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -273,19 +398,149 @@ export function resolveInstallDir(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this process runs inside a container. `/.dockerenv` is created by the
|
||||
* Docker daemon itself; the env var is set by our own Compose file so the check
|
||||
* also holds under runtimes that omit that file.
|
||||
*/
|
||||
export function isRunningInContainer(): boolean {
|
||||
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
|
||||
}
|
||||
|
||||
function detectInstallKind(dir: string): InstallKind {
|
||||
if (existsSync(join(dir, '.git'))) return 'git';
|
||||
// A container whose code is a bind-mounted checkout updates in place (the pull
|
||||
// and build land on the host filesystem and survive container recreation). A
|
||||
// container WITHOUT that mount runs a baked image copy — a pull there would go
|
||||
// to the writable layer and vanish on the next `up`, so it is not updatable.
|
||||
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
|
||||
// Global npm install ships only dist/ (no src/, no .git).
|
||||
if (!existsSync(join(dir, 'src'))) return 'npm';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
|
||||
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
|
||||
return kind === 'git' || kind === 'docker-compose';
|
||||
}
|
||||
|
||||
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
|
||||
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
|
||||
|
||||
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
|
||||
const DOCKERFILE_REL = 'docker/server.Dockerfile';
|
||||
const COMPOSE_REL = 'docker/docker-compose.yaml';
|
||||
const ENV_EXAMPLE_REL = 'docker/.env.example';
|
||||
const ENV_REL = 'docker/.env';
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(text, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
|
||||
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
|
||||
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
|
||||
}
|
||||
|
||||
function readFileOrNull(path: string): string | null {
|
||||
try {
|
||||
return readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprints the RUNNING container was created from, recorded on the host
|
||||
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
|
||||
* container started before this file existed) — `computeEnvironmentBlockers()`
|
||||
* deliberately treats an unknown baseline as "not a blocker".
|
||||
*/
|
||||
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
|
||||
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
|
||||
if (!raw) return { dockerfile: null, compose: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
|
||||
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
|
||||
} catch {
|
||||
return { dockerfile: null, compose: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart policy of the container we're running in, via the mounted Docker
|
||||
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
|
||||
* is not a blocker (see `computeEnvironmentBlockers`).
|
||||
*/
|
||||
function detectOwnRestartPolicy(): string | null {
|
||||
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
|
||||
// value when the env var is absent. A custom `hostname:` in the compose file
|
||||
// makes both unresolvable to the daemon, which fails open (unknown is not a
|
||||
// blocker) rather than refusing an update over a cosmetic setting.
|
||||
const id = process.env.HOSTNAME || hostname();
|
||||
if (!id) return null;
|
||||
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
|
||||
return out && out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
|
||||
* tag's files straight out of git (`git show`), so nothing is checked out and the
|
||||
* answer is available at CHECK time — the UI can refuse before the user commits
|
||||
* to an update.
|
||||
*/
|
||||
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
|
||||
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
|
||||
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
|
||||
// this tag would read nothing and report a falsely clean gate. Fetch the one
|
||||
// ref first (cheap: it deltas against what the clone already has) and only
|
||||
// then read. The updater fetches the same ref again; both are idempotent.
|
||||
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
|
||||
tryExec(
|
||||
'git',
|
||||
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
|
||||
installDir,
|
||||
CHECK_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
|
||||
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
|
||||
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
|
||||
|
||||
// No environment files at the target tag at all: we cannot judge, so say so
|
||||
// rather than reporting a clean gate the caller would trust.
|
||||
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
|
||||
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
const applied = readAppliedEnvironmentFingerprints();
|
||||
const userEnv = readFileOrNull(join(installDir, ENV_REL));
|
||||
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
appliedDockerfileHash: applied.dockerfile,
|
||||
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
|
||||
appliedComposeHash: applied.compose,
|
||||
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
|
||||
// A missing/unreadable .env cannot be diffed — report no missing keys rather
|
||||
// than every key, which would block on an install using a non-standard path.
|
||||
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
|
||||
restartPolicy: detectOwnRestartPolicy(),
|
||||
});
|
||||
|
||||
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which init system supervises us. Detection happens HERE (in the running
|
||||
* server, which has a rich env) and the result is passed to the updater script —
|
||||
* the detached child must not re-probe with a stripped-down environment.
|
||||
*/
|
||||
export function detectSupervisor(): SupervisorKind {
|
||||
// Checked FIRST: a container has no init system of its own, and its "restart"
|
||||
// is the server exiting so the Docker restart policy relaunches it. Probing
|
||||
// systemd here would find nothing and report `none`, which stages the update
|
||||
// and then asks the user to restart by hand for no reason.
|
||||
if (isRunningInContainer()) return 'docker-compose';
|
||||
if (process.platform === 'darwin') {
|
||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
||||
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
|
||||
@@ -389,17 +644,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
checkedAt,
|
||||
source: 'none',
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — self-update is unavailable.' };
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
...base,
|
||||
error:
|
||||
info.installKind === 'unknown' && isRunningInContainer()
|
||||
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
|
||||
: 'Not a git install — self-update is unavailable.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Attach the container environment gate to a finished check result. */
|
||||
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
|
||||
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
|
||||
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
|
||||
};
|
||||
|
||||
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
|
||||
const gh = remote ? parseGitHubRepo(remote) : null;
|
||||
|
||||
if (gh) {
|
||||
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
|
||||
if (rel) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: rel.version,
|
||||
latestTag: rel.tag,
|
||||
@@ -407,20 +674,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
htmlUrl: rel.htmlUrl,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
|
||||
source: 'github-api',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
|
||||
const viaGit = fetchLatestTagViaGit(info.installDir);
|
||||
if (viaGit) {
|
||||
return {
|
||||
return withGate({
|
||||
...base,
|
||||
latestVersion: viaGit.version,
|
||||
latestTag: viaGit.tag,
|
||||
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
|
||||
source: 'git-ls-remote',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
|
||||
@@ -432,7 +699,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
|
||||
|
||||
export type StartUpdateResult =
|
||||
| { ok: true; updateId: string; toTag: string; toVersion: string | null }
|
||||
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string };
|
||||
| {
|
||||
ok: false;
|
||||
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the updater script OUT of the repo before running it. The script lives in
|
||||
@@ -497,11 +768,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
if (!info.selfUpdateEnabled) {
|
||||
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
|
||||
}
|
||||
if (info.installKind !== 'git') {
|
||||
if (!canSelfUpdateInPlace(info.installKind)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'not-git',
|
||||
message: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
message: isRunningInContainer()
|
||||
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
|
||||
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
|
||||
};
|
||||
}
|
||||
const existing = readUpdateStatus();
|
||||
@@ -517,6 +790,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
||||
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
|
||||
}
|
||||
|
||||
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
|
||||
// button when the gate blocks, but the endpoint is reachable directly and the
|
||||
// release could have moved between the check and the click.
|
||||
if (info.installKind === 'docker-compose') {
|
||||
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
|
||||
if (gate.blockers.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'env-blocked',
|
||||
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
|
||||
const runner = stageRunner(info.installDir);
|
||||
if (!runner) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview Static parity check between docker/docker-compose.yaml and
|
||||
* docker/.env.example.
|
||||
*
|
||||
* This is the MERGE GATE for the container environment. A feature that needs a
|
||||
* new setting must add it to BOTH files; forgetting one is what produces the
|
||||
* failure the in-app updater cannot defend against, because Compose resolves an
|
||||
* unset `${VAR}` to the EMPTY STRING and starts anyway — the container comes up
|
||||
* with a silently blank setting and misbehaves later, far from the cause.
|
||||
*
|
||||
* Failing here costs a line in a PR. Failing in production costs a debugging
|
||||
* session on someone else's server. Related: docs/docker-self-update.md.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parseEnvKeys } from '../src/web/self-update.js';
|
||||
|
||||
const DOCKER_DIR = join(process.cwd(), 'docker');
|
||||
const compose = readFileSync(join(DOCKER_DIR, 'docker-compose.yaml'), 'utf-8');
|
||||
const example = readFileSync(join(DOCKER_DIR, '.env.example'), 'utf-8');
|
||||
|
||||
/**
|
||||
* Every `${VAR}` / `${VAR:-default}` the compose file interpolates. Compose's
|
||||
* own built-ins are excluded — they are supplied by Compose, not by .env.
|
||||
*/
|
||||
function composeVariables(text: string): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const m of text.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g)) found.add(m[1]);
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/** Keys .env.example mentions at all, including the commented-out optional ones. */
|
||||
function documentedKeys(text: string): Set<string> {
|
||||
const keys = new Set(parseEnvKeys(text));
|
||||
for (const m of text.matchAll(/^#\s*([A-Z_][A-Z0-9_]*)=/gm)) keys.add(m[1]);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables Compose or the start script provides, which therefore need no entry
|
||||
* in .env.example. Keep this list SHORT and justified — every addition is a
|
||||
* setting the parity check stops guarding.
|
||||
*/
|
||||
const PROVIDED_ELSEWHERE = new Set([
|
||||
// Derived by docker/Start-Codeman.sh from the appdata dir and socket owner.
|
||||
'PUID',
|
||||
'PGID',
|
||||
'DOCKER_SOCKET_GID',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys .env.example sets for an OVERRIDE documented in docker/README.md (the
|
||||
* macvlan networking example), which the base compose file deliberately does not
|
||||
* read. They are settings for a file that is not this one, not dead entries.
|
||||
*/
|
||||
const EXAMPLE_ONLY_KEYS = new Set([
|
||||
'CODEMAN_MACVLAN_NETWORK',
|
||||
'CODEMAN_IPV4_ADDRESS',
|
||||
'CODEMAN_MAC_ADDRESS',
|
||||
'CODEMAN_MACVLAN_PARENT',
|
||||
'CODEMAN_MACVLAN_SUBNET',
|
||||
'CODEMAN_MACVLAN_GATEWAY',
|
||||
]);
|
||||
|
||||
describe('docker compose ↔ .env.example parity', () => {
|
||||
it('every variable the compose file reads is documented in .env.example', () => {
|
||||
const documented = documentedKeys(example);
|
||||
const undocumented = composeVariables(compose).filter((v) => !documented.has(v) && !PROVIDED_ELSEWHERE.has(v));
|
||||
expect(undocumented, `add these to docker/.env.example: ${undocumented.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('every key .env.example SETS is actually read by the compose file', () => {
|
||||
// Commented-out entries are exempt: they document optional overrides and
|
||||
// example-only values (the macvlan block) that the base file never reads.
|
||||
const used = new Set(composeVariables(compose));
|
||||
const unused = parseEnvKeys(example).filter((k) => !used.has(k) && !EXAMPLE_ONLY_KEYS.has(k));
|
||||
expect(unused, `these are set in .env.example but unused: ${unused.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the Docker Compose self-update path.
|
||||
*
|
||||
* Covers the PURE half of the container environment gate: which release changes
|
||||
* can be applied by the container restarting itself, and which must go back to
|
||||
* the host. The IO half (`evaluateEnvironmentGate`) shells out to git and docker
|
||||
* and is exercised by hand — see docs/docker-self-update.md.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
canSelfUpdateInPlace,
|
||||
computeEnvironmentBlockers,
|
||||
diffRequiredEnvKeys,
|
||||
isAutoRestartPolicy,
|
||||
parseEnvKeys,
|
||||
type EnvironmentGateInput,
|
||||
} from '../src/web/self-update.js';
|
||||
|
||||
/** A gate input where nothing has changed — each test perturbs one field. */
|
||||
const CLEAN: EnvironmentGateInput = {
|
||||
appliedDockerfileHash: 'aaa',
|
||||
targetDockerfileHash: 'aaa',
|
||||
appliedComposeHash: 'bbb',
|
||||
targetComposeHash: 'bbb',
|
||||
missingEnvKeys: [],
|
||||
restartPolicy: 'unless-stopped',
|
||||
};
|
||||
|
||||
describe('canSelfUpdateInPlace', () => {
|
||||
it('accepts git and docker-compose, rejects npm and unknown', () => {
|
||||
expect(canSelfUpdateInPlace('git')).toBe(true);
|
||||
expect(canSelfUpdateInPlace('docker-compose')).toBe(true);
|
||||
expect(canSelfUpdateInPlace('npm')).toBe(false);
|
||||
// A container with no repo mounted: a pull would land in the writable layer.
|
||||
expect(canSelfUpdateInPlace('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnvKeys', () => {
|
||||
it('reads set keys and ignores blanks, comments and values', () => {
|
||||
expect(parseEnvKeys('A=1\n\nB=two words\n')).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('does NOT treat a commented-out key as set', () => {
|
||||
// .env.example documents optional overrides as `# PUID=1000`. Counting those
|
||||
// as required would block every update on settings the user should not set.
|
||||
expect(parseEnvKeys('# PUID=1000\nCODEMAN_PORT=3000')).toEqual(['CODEMAN_PORT']);
|
||||
});
|
||||
|
||||
it('handles `export` prefixes and repeated keys', () => {
|
||||
expect(parseEnvKeys('export A=1\nA=2\n')).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('ignores lines that are not assignments', () => {
|
||||
expect(parseEnvKeys('just a line\n=novalue\n1BAD=x\nOK=y')).toEqual(['OK']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffRequiredEnvKeys', () => {
|
||||
it('reports keys the release added that the user has no value for', () => {
|
||||
expect(diffRequiredEnvKeys('A=\nB=\nC=', 'A=1\nC=3')).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('ignores keys the user set that the release dropped', () => {
|
||||
expect(diffRequiredEnvKeys('A=', 'A=1\nOBSOLETE=2')).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a key the user set to an EMPTY value as present', () => {
|
||||
// `GEMINI_API_KEY=` is a deliberate opt-out, not a missing setting.
|
||||
expect(diffRequiredEnvKeys('GEMINI_API_KEY=', 'GEMINI_API_KEY=')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAutoRestartPolicy', () => {
|
||||
it('accepts the policies that relaunch the container after the server exits', () => {
|
||||
expect(isAutoRestartPolicy('unless-stopped')).toBe(true);
|
||||
expect(isAutoRestartPolicy('always')).toBe(true);
|
||||
expect(isAutoRestartPolicy('on-failure')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects "no" and unknown values', () => {
|
||||
expect(isAutoRestartPolicy('no')).toBe(false);
|
||||
expect(isAutoRestartPolicy('')).toBe(false);
|
||||
expect(isAutoRestartPolicy(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEnvironmentBlockers', () => {
|
||||
it('allows a code-only release', () => {
|
||||
expect(computeEnvironmentBlockers(CLEAN)).toEqual([]);
|
||||
});
|
||||
|
||||
it('blocks a release that changes the Dockerfile', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: 'zzz' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['dockerfile-changed']);
|
||||
});
|
||||
|
||||
it('blocks a release that changes the compose file', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetComposeHash: 'zzz' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['compose-changed']);
|
||||
});
|
||||
|
||||
it('blocks and NAMES missing env keys', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, missingEnvKeys: ['CODEMAN_NEW_THING'] });
|
||||
expect(blockers[0].kind).toBe('env-keys-missing');
|
||||
expect(blockers[0].details).toEqual(['CODEMAN_NEW_THING']);
|
||||
});
|
||||
|
||||
it('blocks when the container would not come back', () => {
|
||||
const blockers = computeEnvironmentBlockers({ ...CLEAN, restartPolicy: 'no' });
|
||||
expect(blockers.map((b) => b.kind)).toEqual(['no-auto-restart']);
|
||||
// The message says which policy, so the fix is obvious from the UI alone.
|
||||
expect(blockers[0].message).toContain('"no"');
|
||||
});
|
||||
|
||||
it('reports every blocker at once rather than stopping at the first', () => {
|
||||
const blockers = computeEnvironmentBlockers({
|
||||
...CLEAN,
|
||||
targetDockerfileHash: 'zzz',
|
||||
targetComposeHash: 'yyy',
|
||||
missingEnvKeys: ['A'],
|
||||
restartPolicy: 'no',
|
||||
});
|
||||
expect(blockers.map((b) => b.kind)).toEqual([
|
||||
'dockerfile-changed',
|
||||
'compose-changed',
|
||||
'env-keys-missing',
|
||||
'no-auto-restart',
|
||||
]);
|
||||
});
|
||||
|
||||
// ⚠️ Regression guards for the fail-OPEN decisions. An unknown baseline is not
|
||||
// evidence of a change, and failing closed there would permanently block every
|
||||
// container created before the fingerprint file existed.
|
||||
it('does not block when the applied baseline is unknown', () => {
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, appliedDockerfileHash: null, appliedComposeHash: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not block when the target files cannot be read', () => {
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: null, targetComposeHash: null })).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not block when the restart policy is unknown', () => {
|
||||
// The probe needs the Docker socket, which a user may not have mounted.
|
||||
expect(computeEnvironmentBlockers({ ...CLEAN, restartPolicy: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user