Files
Codeman/test/docker-self-update.test.ts
T
DevvynandClaude Opus 5 66eb01ba8f 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
2026-09-02 19:33:32 +08:00

149 lines
5.6 KiB
TypeScript

/**
* @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([]);
});
});