mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
Merge pull request #373 from opticon454/feature/docker-self-update
feat(docker): restore in-app self-update in the Compose dep
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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