Files
caddy-proxy-manager/tests/unit/l4-port-manager-entrypoint.test.ts
fuomag9 96bac86934 Fix L4 port manager failing to recreate caddy after Docker restart
The sidecar's `docker compose up` command lacked `--pull never`, so
Docker Compose would attempt to pull the caddy image from ghcr.io when
the local image was missing or stale. Since the sidecar has no registry
credentials this failed with 403 Forbidden.

Closes #117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:35:12 +02:00

178 lines
7.7 KiB
TypeScript

/**
* Unit tests for the L4 port manager sidecar entrypoint script.
*
* Tests critical invariants of the shell script:
* - Always applies the override on startup (not just on trigger change)
* - Only recreates the caddy service (never other services)
* - Uses --no-deps to prevent dependency cascades
* - Auto-detects compose project name from caddy container labels
* - Pre-loads LAST_TRIGGER to avoid double-applying on startup
* - Writes status files in valid JSON
* - Never includes test override files in production
* - Supports both named-volume and bind-mount deployments (COMPOSE_HOST_DIR)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const SCRIPT_PATH = resolve(__dirname, '../../docker/l4-port-manager/entrypoint.sh');
const script = readFileSync(SCRIPT_PATH, 'utf-8');
const lines = script.split('\n');
describe('L4 port manager entrypoint.sh', () => {
it('applies override on startup (not only on trigger change)', () => {
// The script must call do_apply before entering the while loop.
// This ensures L4 ports are bound after any restart, because the main
// compose stack starts caddy without the L4 ports override file.
const firstApply = lines.findIndex(l => l.trim().startsWith('do_apply') || l.includes('do_apply'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(firstApply).toBeGreaterThan(-1);
expect(whileLoop).toBeGreaterThan(-1);
expect(firstApply).toBeLessThan(whileLoop);
});
it('pre-loads LAST_TRIGGER after startup apply to avoid double-apply', () => {
// After the startup apply, LAST_TRIGGER must be set from the current trigger
// file content so the poll loop doesn't re-apply the same trigger again.
const lastTriggerInit = lines.findIndex(l => l.includes('LAST_TRIGGER=') && l.includes('TRIGGER_FILE'));
const whileLoop = lines.findIndex(l => l.includes('while true'));
expect(lastTriggerInit).toBeGreaterThan(-1);
expect(lastTriggerInit).toBeLessThan(whileLoop);
});
it('only recreates the caddy service', () => {
// The docker compose command should target only "caddy" — never "web" or other services
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
expect(composeUpLines.length).toBeGreaterThan(0);
for (const line of composeUpLines) {
expect(line).toContain('caddy');
expect(line).not.toMatch(/\bweb\b/);
}
});
it('uses --no-deps flag to prevent dependency cascades', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--no-deps');
}
});
it('uses --force-recreate to ensure port changes take effect', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--force-recreate');
}
});
it('specifies project name to target the correct compose stack', () => {
// Without -p, compose would infer the project from the mount directory name
// ("/compose") rather than the actual running stack name, causing it to
// create new containers instead of recreating the existing ones.
expect(script).toMatch(/COMPOSE_ARGS=.*-p \$COMPOSE_PROJECT/);
});
it('auto-detects project name from caddy container labels', () => {
expect(script).toContain('com.docker.compose.project');
expect(script).toContain('docker inspect');
expect(script).toContain('detect_project_name');
});
it('compares trigger content to avoid redundant restarts', () => {
expect(script).toContain('LAST_TRIGGER');
expect(script).toContain('CURRENT_TRIGGER');
expect(script).toContain('"$CURRENT_TRIGGER" = "$LAST_TRIGGER"');
});
it('uses --pull never to avoid registry pulls (only recreates)', () => {
const composeUpLines = lines.filter(line =>
line.includes('docker compose') && line.includes('up')
);
for (const line of composeUpLines) {
expect(line).toContain('--pull never');
expect(line).not.toContain('--build');
}
});
it('waits for caddy health check after recreation', () => {
expect(script).toContain('Health');
expect(script).toContain('healthy');
expect(script).toContain('HEALTH_TIMEOUT');
});
it('writes status for both success and failure cases', () => {
const statusWrites = lines.filter(l => l.trim().startsWith('write_status'));
// At least: startup idle/applying, applying, applied/success, failed
expect(statusWrites.length).toBeGreaterThanOrEqual(4);
});
it('does not include test override files in production', () => {
// Including docker-compose.test.yml would override web env vars (triggering
// web restart) and switch to test volume names.
expect(script).not.toContain('docker-compose.test.yml');
});
it('does not restart the web service or itself', () => {
const dangerousPatterns = [
/up.*\bweb\b/,
/restart.*\bweb\b/,
/up.*\bl4-port-manager\b/,
/restart.*\bl4-port-manager\b/,
];
for (const pattern of dangerousPatterns) {
expect(script).not.toMatch(pattern);
}
});
// ---------------------------------------------------------------------------
// Deployment scenario: COMPOSE_HOST_DIR (bind-mount / cloud override)
// ---------------------------------------------------------------------------
it('uses --project-directory $COMPOSE_HOST_DIR when COMPOSE_HOST_DIR is set', () => {
// Bind-mount deployments (docker-compose.override.yml replaces named volumes
// with ./data bind mounts). Relative paths like ./geoip-data in the override
// file must resolve against the HOST project directory, not the sidecar's
// /compose mount. --project-directory tells the Docker daemon where to look.
expect(script).toContain('--project-directory $COMPOSE_HOST_DIR');
// It must be conditional — only applied when COMPOSE_HOST_DIR is non-empty
expect(script).toMatch(/if \[ -n "\$COMPOSE_HOST_DIR" \]/);
});
it('does NOT unconditionally add --project-directory (named-volume deployments work without it)', () => {
// Standard deployments (no override file) use named volumes — no host path
// is needed. --project-directory must NOT be hardcoded outside the conditional.
const unconditional = lines.filter(l =>
l.includes('--project-directory') && !l.includes('COMPOSE_HOST_DIR') && !l.trim().startsWith('#')
);
expect(unconditional).toHaveLength(0);
});
it('uses --env-file from $COMPOSE_DIR (container-accessible path), not $COMPOSE_HOST_DIR', () => {
// When --project-directory points to the host path, Docker Compose looks for
// .env at $COMPOSE_HOST_DIR/.env which is NOT mounted inside the container.
// We must explicitly pass --env-file $COMPOSE_DIR/.env (the container mount).
expect(script).toContain('--env-file $COMPOSE_DIR/.env');
// Must NOT reference the host dir for the env file
expect(script).not.toContain('--env-file $COMPOSE_HOST_DIR');
});
it('always reads compose files from $COMPOSE_DIR regardless of COMPOSE_HOST_DIR', () => {
// The sidecar mounts the project at /compose (COMPOSE_DIR). Whether or not
// COMPOSE_HOST_DIR is set, all -f flags must reference container-accessible
// paths under $COMPOSE_DIR, never the host path.
const composeFileFlags = lines.filter(l =>
l.includes('-f ') && l.includes('docker-compose')
);
expect(composeFileFlags.length).toBeGreaterThan(0);
for (const line of composeFileFlags) {
expect(line).toContain('$COMPOSE_DIR');
expect(line).not.toContain('$COMPOSE_HOST_DIR');
}
});
});