Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
178 lines
7.7 KiB
TypeScript
Executable File
178 lines
7.7 KiB
TypeScript
Executable File
/**
|
|
* 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');
|
|
}
|
|
});
|
|
});
|