diff --git a/docker/README.md b/docker/README.md index cdd169f5..91fded40 100644 --- a/docker/README.md +++ b/docker/README.md @@ -41,6 +41,26 @@ Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to 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). +### Major updates + +`Start-Codeman.sh` rebuilds the image and clears the build-artefact volumes on +its own, but only when it detects the checkout's HEAD or `package-lock.json` +moved — exactly right for an ordinary `git pull`, too conservative when a +release note (or the updater's own blocker message) calls for starting over. +For that case, `docker/Update-Codeman.sh` stops the stack, force-rebuilds the +image with no layer cache, then hands off to `Start-Codeman.sh` for the usual +start: + +```sh +bash docker/Update-Codeman.sh +``` + +Add `--volumes` to also clear the `codeman-node-modules`/`codeman-dist` +volumes — the scripted form of "Resetting the build artefacts" in +[`../docs/docker-self-update.md`](../docs/docker-self-update.md). Those two +are the only named volumes this stack declares; application data and case +workspaces are host bind mounts and are never touched either way. + ## Local customisation Compose merges `docker-compose.override.yml` on top of `docker-compose.yaml`. Keep host-specific changes there rather than editing `docker-compose.yaml`, so this repository can be updated without losing them. Both `docker-compose.override.yml` and `docker-compose.override.yaml` are ignored by Git. diff --git a/docker/Update-Codeman.sh b/docker/Update-Codeman.sh new file mode 100644 index 00000000..63cdf497 --- /dev/null +++ b/docker/Update-Codeman.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# The scripted major-update path for the Docker Compose deployment. +# +# docker/README.md and docs/docker-self-update.md both point operators here for +# anything the in-app updater itself refuses to apply: a changed +# `server.Dockerfile`, a changed `docker-compose.yaml`, or a new required +# `.env.example` key. None of those can be applied by a container restarting +# itself — a restart reuses the existing image and configuration (see "The +# environment gate" in docs/docker-self-update.md) — so this script does the +# three things an in-place update cannot: stop the stack, force a real image +# rebuild with no layer cache, then hand off to Start-Codeman.sh for the same +# careful PUID/PGID, override-file and fingerprint handling every other start +# goes through. +# +# This is the scripted form of "Resetting the build artefacts" in +# docs/docker-self-update.md (`docker compose down -v`, then +# `Start-Codeman.sh`), plus the unconditional `--no-cache` a major update +# warrants: `Start-Codeman.sh` on its own only rebuilds without the cache flag, +# and only clears the two build-artefact volumes when it detects the checkout's +# HEAD or `package-lock.json` moved — exactly right for an ordinary `git pull`, +# too conservative when the ask is "start over, certain of what ships". +# +# Usage: docker/Update-Codeman.sh [--volumes] +# --volumes, -v Also remove the codeman-node-modules/codeman-dist named +# volumes, so the fresh image's own node_modules/dist are +# what actually run instead of sitting unused behind a +# Docker-seeded volume's old content (Docker only seeds a +# named volume from the image while that volume is EMPTY). +# Safe: those two are the ONLY named volumes this stack +# declares (`docker-compose.yaml`) — application data and +# case workspaces are host bind mounts, never touched by +# `docker compose down`, with or without this flag. + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +env_file="$script_dir/.env" +compose_file="$script_dir/docker-compose.yaml" + +remove_volumes=0 +for arg in "$@"; do + case "$arg" in + --volumes | -v) + remove_volumes=1 + ;; + *) + printf 'Error: unrecognised argument: %s\n' "$arg" >&2 + printf 'Usage: %s [--volumes]\n' "$0" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f "$env_file" ]]; then + printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 + printf 'Create it from %s/.env.example before running this script.\n' "$script_dir" >&2 + exit 1 +fi + +# Same override-file discovery as Start-Codeman.sh, and deliberately kept in +# step with it: a stack started through one script and updated through the +# other must resolve to the exact same Compose files, or `down` here and `up` +# there could target different configurations. Compose's own precedence +# (measured on v5.5.0 with both present: it uses .yml and ignores .yaml). +override_yml="$script_dir/docker-compose.override.yml" +override_yaml="$script_dir/docker-compose.override.yaml" +if [[ -f "$override_yml" && -f "$override_yaml" ]]; then + printf 'Warning: both %s and %s exist; Compose uses .yml and ignores .yaml.\n' \ + "$override_yml" "$override_yaml" >&2 +fi +compose_files=(-f "$compose_file") +for override_file in "$override_yml" "$override_yaml"; do + if [[ -f "$override_file" ]]; then + compose_files+=(-f "$override_file") + printf 'Using Compose override file: %s\n' "$override_file" + break + fi +done +compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}") + +printf 'Stopping the stack...\n' +if [[ "$remove_volumes" == '1' ]]; then + printf 'Also removing the codeman-node-modules/codeman-dist volumes (--volumes).\n' + "${compose_command[@]}" down --volumes +else + "${compose_command[@]}" down +fi + +# --no-cache, always: a plain `build` reuses cached layers (npm install, apt +# packages, the CLI installs baked into the image) and can silently keep them +# frozen at whatever they were the day the cache was populated — exactly wrong +# for a major update, whose whole point is being certain of what actually +# ships. `scripts/build-agent-image.mjs` makes the same call for the same +# reason (see its entry in CLAUDE.md's Additional Commands table). +printf 'Building a fresh image (--no-cache)...\n' +"${compose_command[@]}" build --no-cache + +# Start-Codeman.sh does everything a plain `up -d` does not: resolves +# PUID/PGID from CODEMAN_APPDATA_PATH's owner, pre-creates CODEMAN_CASES_PATH +# with the right ownership, resolves DOCKER_SOCKET_GID, records the +# server.Dockerfile/docker-compose.yaml fingerprint the in-app updater's gate +# reads on every future update, and clears the build-artefact volumes itself +# if it finds the checkout's source moved since the last start. Reimplementing +# any of that here would only risk drifting out of step with it — hand off +# instead, exactly as docs/docker-self-update.md's own reset procedure does. +printf 'Handing off to Start-Codeman.sh...\n' +exec "$script_dir/Start-Codeman.sh" diff --git a/docs/docker-self-update.md b/docs/docker-self-update.md index 6f41505b..42bd6fc0 100644 --- a/docs/docker-self-update.md +++ b/docs/docker-self-update.md @@ -224,7 +224,9 @@ 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. +image. `docker/Update-Codeman.sh --volumes` scripts exactly this (plus an +unconditional `--no-cache` rebuild, which a plain `Start-Codeman.sh` run does not +force on its own) — see "Major updates" in `docker/README.md`. ## Disabling it diff --git a/test/docker-entrypoint.test.ts b/test/docker-entrypoint.test.ts index 94823c21..b4a19792 100644 --- a/test/docker-entrypoint.test.ts +++ b/test/docker-entrypoint.test.ts @@ -21,7 +21,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { readFileSync, mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -33,6 +33,7 @@ const compose = read('docker/docker-compose.yaml'); const entrypoint = read('docker/entrypoint.sh'); const dockerfile = read('docker/server.Dockerfile'); const startScript = read('docker/Start-Codeman.sh'); +const updateScript = read('docker/Update-Codeman.sh'); /** The `- NAME` entries under `cap_add:` (the block ends at the next key at the same indent). */ function composeCapAdd(text: string): string[] { @@ -174,6 +175,59 @@ describe('Start-Codeman.sh', () => { }); }); +describe('Update-Codeman.sh (the scripted major-update path — docker/README.md "Major updates")', () => { + it('parses under bash -n', () => { + execFileSync('bash', ['-n', join(ROOT, 'docker/Update-Codeman.sh')]); + }); + + it('is executable, like every other script this deployment runs directly', () => { + // Windows checkouts (this repo is developed on both) do not carry a real + // execute bit, so this only meaningfully asserts on POSIX — matching how + // docker/README.md documents running it (`bash docker/Update-Codeman.sh`, + // not `./docker/Update-Codeman.sh`) either way. + if (process.platform === 'win32') return; + const mode = statSync(join(ROOT, 'docker/Update-Codeman.sh')).mode; + expect(mode & 0o111).not.toBe(0); + }); + + it('stops the stack, THEN force-rebuilds with --no-cache, THEN hands off to Start-Codeman.sh', () => { + const down = updateScript.indexOf('"${compose_command[@]}" down'); + const build = updateScript.indexOf('"${compose_command[@]}" build --no-cache'); + const handoff = updateScript.indexOf('exec "$script_dir/Start-Codeman.sh"'); + expect(down).toBeGreaterThan(-1); + expect(build).toBeGreaterThan(down); + expect(handoff).toBeGreaterThan(build); + }); + + it('--volumes (or -v) removes the named volumes on the way down; the default path does not', () => { + expect(updateScript).toMatch(/--volumes \| -v\)\s*\n\s*remove_volumes=1/); + expect(updateScript).toMatch(/"\$\{compose_command\[@\]\}" down --volumes/); + // The unconditional call further down (the else branch) must stay a plain + // `down` — accidentally merging the two branches would silently start + // wiping the build-artefact volumes on every major update, not just when + // the flag is passed. + expect(updateScript).toMatch(/else\s*\n\s*"\$\{compose_command\[@\]\}" down\s*\n\s*fi/); + }); + + it('rejects an unrecognised argument rather than silently ignoring it', () => { + expect(updateScript).toMatch(/Error: unrecognised argument/); + expect(updateScript).toMatch(/exit 1/); + }); + + it('resolves the override file exactly like Start-Codeman.sh, so `down` and `up` never target different Compose files', () => { + // \r stripped before comparing: git's autocrlf normalises the COMMITTED blob to LF + // either way, but a Windows checkout can have already converted one file's line + // endings on disk and not the other's (e.g. Start-Codeman.sh checked out before this + // script existed), which would fail a raw byte comparison for a reason that has + // nothing to do with the two scripts actually agreeing. + const overrideBlock = (script: string) => + script + .slice(script.indexOf('override_yml='), script.indexOf('compose_command=(docker compose')) + .replace(/\r\n/g, '\n'); + expect(overrideBlock(updateScript)).toBe(overrideBlock(startScript)); + }); +}); + describe('git_head_commit resolves every ref layout a checkout can have', () => { let base: string; const git = (cwd: string, ...args: string[]) =>