mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59: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,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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user