/** * Derive Dockerfile + compose files from config image pins. */ import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { mergeDotenvAssignments } from "../drivers/vault-dotenv-parse.ts"; import { assertNoCredentialsInYaml, buildSpecs, buildStackEnv, COMPOSE_OVERRIDE, DOCKER_COMPOSE_OVERRIDE, emitComposeLayers, formatStackEnv, } from "./compose.ts"; import { emitDockerfile } from "./dockerfile.ts"; import { buildCaddyfile } from "./recipes/caddy.ts"; import { buildNginxConf } from "./recipes/nginx.ts"; import { buildPgDogToml, buildPgDogUsersToml, PGDOG_CONFIG_DIR } from "./recipes/pgdog.ts"; import { emitPostgresAdvisorDockerfile, POSTGRES_ADVISOR_DOCKERFILE, postgresAdvisor, } from "./recipes/postgres-advisor.ts"; import type { DeriveOptions, DeriveResult, GeneratedFile } from "./types.ts"; import { DEFAULT_DOCKER_DIR } from "./types.ts"; import { APP_PORT } from "../runtime/types.ts"; /** Legacy llama.cpp entrypoint path (relative to compose dir) — pruned once. */ const LEGACY_LLAMA_ENTRYPOINT_HOST_PATH = "../.oke/llama-entrypoint.py"; /** * Derive infrastructure files from normalised image pins. * * Credentials land only in the returned `stackEnv` (merged into `.env.local`) * and in `pgdog/users.toml` when PgDog is present — never in generated YAML. * User-owned overrides are listed in `composeFiles` but never written. * * @param options - Images / app / prod / layout flags */ export function deriveInfrastructure(options: DeriveOptions): DeriveResult { if (!options.images || Object.keys(options.images).length === 0) { throw new Error( "oke docker: no images configured — set `images` in oke.config.ts (or prod drivers postgres/redis for defaults)", ); } const normalised: DeriveOptions = { ...options, composeDir: options.composeDir ?? DEFAULT_DOCKER_DIR, includeApp: options.includeApp !== false, layout: options.layout ?? "single", }; const specs = buildSpecs(normalised); const { files: composeFilesContent, composeFiles } = emitComposeLayers(specs, normalised); const dockerfile: GeneratedFile = { path: "Dockerfile", content: emitDockerfile({ appPort: normalised.appPort }), }; const stackEnv = buildStackEnv( specs, normalised.recipes ?? [], normalised.host ?? "127.0.0.1", normalised.controls, normalised.instanceId, ); // Always emit Dockerfile for deploy; stack-only runs ignore it. const files = [ dockerfile, ...composeFilesContent, ...pgdogConfigFiles(specs), ...proxyConfigFiles(specs, normalised.appPort), ...postgresAdvisorDockerfileFiles(specs), ]; for (const f of files) { if ( f.path.endsWith(".yml") || f.path === "Dockerfile" || f.path === POSTGRES_ADVISOR_DOCKERFILE ) { assertNoCredentialsInYaml( f.content, specs.map((s) => s.credentials), ); } } return { specs, files, stackEnv, composeFiles }; } /** * Emit `Dockerfile.postgres-advisor` when `store.sql` pins the opt-in advisor image. * * @param specs - Normalised services */ function postgresAdvisorDockerfileFiles(specs: DeriveResult["specs"]): GeneratedFile[] { const sql = specs.find((s) => s.role === "store.sql"); if (!sql || !postgresAdvisor.match(sql.image)) return []; return [ { path: POSTGRES_ADVISOR_DOCKERFILE, content: emitPostgresAdvisorDockerfile(), }, ]; } /** * Emit PgDog TOML configs when both `pgdog` and `store.sql` are in the stack. * * `pgdog/pgdog.toml` has no secrets; `pgdog/users.toml` mirrors store.sql * credentials (same trust boundary as `.env.local` — do not commit). * * @param specs - Normalised services */ function pgdogConfigFiles(specs: DeriveResult["specs"]): GeneratedFile[] { const sql = specs.find((s) => s.role === "store.sql"); const pooler = specs.find((s) => s.role === "pgdog"); if (!sql || !pooler) return []; return [ { path: `${PGDOG_CONFIG_DIR}/pgdog.toml`, content: buildPgDogToml({ database: sql.credentials.database, postgresPort: sql.port, }), }, { path: `${PGDOG_CONFIG_DIR}/users.toml`, content: buildPgDogUsersToml({ user: sql.credentials.user, password: sql.credentials.password, database: sql.credentials.database, }), }, ]; } /** * Emit proxy companion configs when `images.proxy` is pinned. * * Caddy gets a generated `Caddyfile`. nginx gets `nginx.conf`. Traefik * configures via Docker labels (no companion file). * * @param specs - Normalised services * @param appPort - App listen port (default 6530) */ function proxyConfigFiles( specs: DeriveResult["specs"], appPort: number | undefined, ): GeneratedFile[] { const proxy = specs.find((s) => s.role === "proxy"); if (!proxy) return []; const port = appPort ?? APP_PORT; if (/caddy/i.test(proxy.image)) { return [{ path: "Caddyfile", content: buildCaddyfile({ appPort: port }) }]; } if (/nginx/i.test(proxy.image)) { return [{ path: "nginx.conf", content: buildNginxConf({ appPort: port }) }]; } return []; } /** * Write derived files to disk. Never writes user overrides or credential * values into YAML. Prunes stale generated compose / PgDog artefacts from a * previous layout. Optionally merges stack keys into project `.env.local`. * * @param result - Derive result * @param outDir - Destination for Dockerfile / compose (usually `docker/`) * @param options - Write controls */ export async function writeDerivedFiles( result: DeriveResult, outDir: string, options: { readonly writeStackEnv?: boolean; } = {}, ): Promise { const written: string[] = []; const root = outDir.replace(/\/$/, ""); mkdirSync(root, { recursive: true }); const keep = new Set(result.files.map((f) => f.path)); pruneStaleGenerated(root, keep); pruneLegacyLlamaEntrypoint(root); for (const file of result.files) { const path = join(root, file.path); mkdirSync(dirname(path), { recursive: true }); await Bun.write(path, file.content); written.push(path); } if (options.writeStackEnv) { const envPath = join(stackEnvProjectRoot(root), ".env.local"); let existing = ""; try { existing = await Bun.file(envPath).text(); } catch { existing = ""; } const next = existing.trim().length === 0 ? formatStackEnv(result.stackEnv) : mergeDotenvAssignments(existing, result.stackEnv); await Bun.write(envPath, next); written.push(envPath); } return written; } /** * Project root that owns `.env.local` — parent of `docker/` or the compose * dir itself when the layout is flat. * * @param composeOutDir - Derive output directory */ function stackEnvProjectRoot(composeOutDir: string): string { const base = composeOutDir.replace(/\/$/, ""); return basename(base) === DEFAULT_DOCKER_DIR ? dirname(base) : base; } /** Generated compose / companion paths safe to delete when absent from a derive. */ const PRUNE_ROOT_FILES = new Set([ "Dockerfile", POSTGRES_ADVISOR_DOCKERFILE, "docker-compose.yml", "docker-stack.yml", "compose.yml", "compose.all.yml", "compose.prod.yml", "Caddyfile", "nginx.conf", // Legacy: llama.cpp entrypoint used to land in `docker/`. "llama-entrypoint.py", "pgdog.toml", "users.toml", ]); /** * Drop stale `.oke/llama-entrypoint.py` from older stacks (AI recipes removed). * * @param root - Compose directory (`docker/`) */ function pruneLegacyLlamaEntrypoint(root: string): void { const abs = join(root, LEGACY_LLAMA_ENTRYPOINT_HOST_PATH); try { unlinkSync(abs); } catch { // absent or not a file } } /** * Remove previously generated artefacts that the current layout no longer emits. * Never touches user overrides or project `.env.local`. * * @param root - Compose directory * @param keep - Relative paths that will be rewritten */ function pruneStaleGenerated(root: string, keep: ReadonlySet): void { let entries: string[]; try { entries = readdirSync(root); } catch { return; } for (const name of entries) { if (name === COMPOSE_OVERRIDE || name === DOCKER_COMPOSE_OVERRIDE) continue; const abs = join(root, name); let st: ReturnType; try { st = statSync(abs); } catch { continue; } if (st.isDirectory()) { if (name === PGDOG_CONFIG_DIR) { prunePgDogDir(abs, keep); } continue; } if (!st.isFile()) continue; const rel = name; if (keep.has(rel)) continue; if (PRUNE_ROOT_FILES.has(name) || /^compose\..+\.yml$/.test(name)) { try { unlinkSync(abs); } catch { // best-effort } } } } /** * Drop legacy / unused files under `pgdog/`. * * @param dir - Absolute `pgdog/` path * @param keep - Relative keep set (`pgdog/…`) */ function prunePgDogDir(dir: string, keep: ReadonlySet): void { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const name of entries) { const rel = `${PGDOG_CONFIG_DIR}/${name}`; if (keep.has(rel)) continue; try { unlinkSync(join(dir, name)); } catch { // best-effort } } try { if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true }); } catch { // best-effort } }