evOo-local/infra/updater/src/compose-images.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

186 lines
7.4 KiB
TypeScript

import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
interface ComposeService {
image?: string;
build?: unknown;
command?: unknown;
env_file?: unknown;
/** YAML may parse unquoted scalars as null/number/boolean. */
environment?: Record<string, unknown>;
volumes?: string[];
ports?: unknown[];
user?: string;
restart?: string;
network_mode?: string;
networks?: string[];
}
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const composeFile = path.resolve(repoRoot, "infra/compose/docker-compose.images.yml");
const publishWorkflowFile = path.resolve(repoRoot, ".github/workflows/publish-server-image.yml");
const compose = parse(readFileSync(composeFile, "utf8")) as {
services: Record<string, ComposeService>;
};
const publishWorkflow = parse(readFileSync(publishWorkflowFile, "utf8")) as {
jobs?: {
publish?: {
strategy?: {
matrix?: {
include?: Array<{ name?: string }>;
};
};
};
};
};
const appServices = ["api", "worker", "web", "supervisor"] as const;
const FIRST_PARTY_IMAGE = /ghcr\.io\/elie222\/rakazo\/([a-z0-9][a-z0-9._-]*)/g;
function firstPartyImageNames(value: unknown): string[] {
if (typeof value !== "string") return [];
return [...value.matchAll(FIRST_PARTY_IMAGE)].map((match) => match[1] ?? "");
}
/**
* The images compose file is the no-checkout happy path. It must stay pull-only and self-contained
* so operators can drop it next to a .env outside any git worktree. Local Docker computers run via
* an in-stack supervisor (app image + docker.sock) that stays unpublished on the host.
*/
describe("the images compose file", () => {
it("runs postgres, app roles, supervisor, and a published computer image", () => {
expect(Object.keys(compose.services).sort()).toEqual([
"api",
"computer",
"data-init",
"postgres",
"supervisor",
"web",
"worker",
]);
for (const service of appServices) {
expect(compose.services[service]?.image).toContain("ghcr.io/elie222/rakazo/app");
expect(compose.services[service]?.image).toContain("RAKAZO_IMAGE_TAG");
}
expect(compose.services.computer?.image).toContain("ghcr.io/elie222/rakazo/computer");
expect(compose.services.computer?.image).toContain("RAKAZO_COMPUTER_IMAGE_TAG");
expect(compose.services.postgres?.image).toMatch(
/^\$\{POSTGRES_IMAGE:-postgres:16@sha256:[0-9a-f]{64}\}$/,
);
expect(compose.services["data-init"]?.image).toMatch(/^\$\{BUSYBOX_IMAGE:-busybox:1\}$/);
});
it("skips non-string Compose environment scalars when collecting image names", () => {
expect(firstPartyImageNames(null)).toEqual([]);
expect(firstPartyImageNames(true)).toEqual([]);
expect(firstPartyImageNames(7091)).toEqual([]);
expect(firstPartyImageNames("ghcr.io/elie222/rakazo/computer:edge")).toEqual(["computer"]);
});
it("only references first-party images that the publish matrix publishes", () => {
const published = new Set(
(publishWorkflow.jobs?.publish?.strategy?.matrix?.include ?? [])
.map((entry) => entry.name)
.filter((name): name is string => typeof name === "string" && name.length > 0),
);
const referenced = new Set<string>();
for (const service of Object.values(compose.services)) {
for (const name of firstPartyImageNames(service.image)) {
referenced.add(name);
}
for (const value of Object.values(service.environment ?? {})) {
for (const name of firstPartyImageNames(value)) {
referenced.add(name);
}
}
}
expect(published.size).toBeGreaterThan(0);
expect(referenced.has("computer")).toBe(true);
for (const name of referenced) {
expect(
published.has(name),
`${name} referenced by images compose but omitted from publish matrix`,
).toBe(true);
}
});
it("passes optional HTTP(S)_PROXY / NO_PROXY into api and worker", () => {
for (const name of ["api", "worker"] as const) {
const env = compose.services[name]?.environment ?? {};
expect(env.HTTP_PROXY).toBe("${HTTP_PROXY:-${http_proxy:-}}");
expect(env.HTTPS_PROXY).toBe("${HTTPS_PROXY:-${https_proxy:-}}");
expect(env.NO_PROXY).toBe("${NO_PROXY:-${no_proxy:-}}");
expect(env.http_proxy).toBe("${http_proxy:-${HTTP_PROXY:-}}");
expect(env.https_proxy).toBe("${https_proxy:-${HTTPS_PROXY:-}}");
expect(env.no_proxy).toBe("${no_proxy:-${NO_PROXY:-}}");
}
});
it("does not allocate a default network for offline init services", () => {
for (const name of ["computer", "data-init"]) {
expect(compose.services[name]?.network_mode).toBe("none");
expect(compose.services[name]?.networks).toBeUndefined();
}
for (const name of ["postgres", ...appServices]) {
expect(compose.services[name]?.networks?.length).toBeGreaterThan(0);
}
});
it("never builds from a checkout", () => {
for (const service of Object.values(compose.services)) {
expect(service.build).toBeUndefined();
}
});
it("loads secrets from a colocated .env", () => {
expect(compose.services.api?.env_file).toEqual([".env"]);
expect(compose.services.worker?.env_file).toEqual([".env"]);
});
it("defaults API and worker to Docker computers via the supervisor", () => {
expect(compose.services.api?.environment?.SANDBOX_PROVIDER).toContain("docker");
expect(compose.services.worker?.environment?.SANDBOX_PROVIDER).toContain("docker");
expect(compose.services.api?.environment?.SANDBOX_SUPERVISOR_URL).toBe(
"http://supervisor:7091",
);
expect(compose.services.worker?.environment?.SANDBOX_SUPERVISOR_URL).toBe(
"http://supervisor:7091",
);
});
it("keeps the Docker socket on the unpublished supervisor only", () => {
for (const [name, service] of Object.entries(compose.services)) {
const hasSocket = (service.volumes ?? []).some((volume) => volume.includes("docker.sock"));
if (name === "supervisor") {
expect(hasSocket).toBe(true);
expect(service.ports).toBeUndefined();
expect(service.user).toBe("root");
expect(String(service.command)).toContain("sandbox-supervisor");
} else {
expect(hasSocket).toBe(false);
}
}
});
it("publishes the web UI on loopback only", () => {
expect(compose.services.web?.ports).toEqual(["127.0.0.1:${RAKAZO_WEB_PORT:-5173}:5173"]);
expect(compose.services.api?.ports).toEqual(["127.0.0.1:${RAKAZO_API_PORT:-3100}:3100"]);
for (const key of ["BETTER_AUTH_URL", "WEB_ORIGIN", "API_URL"]) {
expect(compose.services.api?.environment?.[key]).toBe(`\${${key}:-http://127.0.0.1:5173}`);
}
expect(compose.services.postgres?.ports).toBeUndefined();
expect(compose.services.supervisor?.ports).toBeUndefined();
});
it("passes logging variables to the supervisor without putting them on computer containers", () => {
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is the literal Compose expression
expect(compose.services.supervisor?.environment?.AXIOM_TOKEN).toBe("${AXIOM_TOKEN:-}");
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is the literal Compose expression
expect(compose.services.supervisor?.environment?.AXIOM_DATASET).toBe("${AXIOM_DATASET:-}");
expect(compose.services.computer?.environment?.AXIOM_TOKEN).toBeUndefined();
expect(compose.services.computer?.environment?.LOG_LEVEL).toBeUndefined();
});
});