diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 162db4f..4a5c08e 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -4,9 +4,11 @@ import { initConfig } from "../config/mod.ts"; import { resolveConfig } from "../config/mod.ts"; import { ExitCode } from "../config/types.ts"; import { generateStacks } from "../compose/mod.ts"; -import type { GenerateOptions } from "../compose/mod.ts"; -import { join } from "@std/path"; -import { exists } from "@std/fs"; +import type { ComposeData, GenerateOptions } from "../compose/mod.ts"; +import { join, resolve } from "@std/path"; +import { ensureDir, exists } from "@std/fs"; +import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; +import { renderStack } from "../render/mod.ts"; /** * Parse and execute CLI commands. @@ -65,6 +67,7 @@ export function buildCli(): Command { detect, preset, profile, + writeGitignore, force, dryRun, cwd: Deno.cwd(), @@ -100,6 +103,10 @@ export function buildCli(): Command { .option("--stacks ", "Comma-separated list of stack names to generate.") .option("--output-dir ", "Write generated stacks to a specific directory.") .option("--profile ", "Use a specific profile.") + .option( + "--override ", + "Comma-separated list of override files to apply.", + ) .action(async (options: Record) => { try { const profile = options.profile as string | undefined; @@ -108,15 +115,19 @@ export function buildCli(): Command { const config = await resolveConfig({ profile, cwd: Deno.cwd() }); const repoRoot = config.base.repoRoot ?? Deno.cwd(); + // Parse override file paths + const overrideFiles = options.override + ? (options.override as string).split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const genOptions: GenerateOptions = { stacks: options.stacks ? (options.stacks as string).split(",").map((s: string) => s.trim()) : undefined, - configStackNames: config.base.stack.names, repoRoot, outputDir: options.outputDir as string | undefined, dryRun, - network: config.base.stack.network, + overrides: overrideFiles, }; const result = await generateStacks(genOptions); @@ -163,9 +174,91 @@ export function buildCli(): Command { "--override ", "Comma-separated list of override files to apply before rendering.", ) - .action(() => { - console.error("render: not yet implemented (issue #5)"); - Deno.exit(1); + .option("--dry-run", "Print rendered output without writing files.") + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const strict = options.strict as boolean | undefined; + const dryRun = options.dryRun as boolean | undefined; + const outputDir = options.outputDir as string | undefined; + + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + const renderOutputDir = outputDir || config.base.render.outputDirectory; + + // 1. Generate stacks (in memory) + const genResult = await generateStacks({ + stacks: options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : undefined, + repoRoot, + outputDir: undefined, // generate in memory only + dryRun: true, // generate in memory for render + overrides: options.override + ? (options.override as string).split(",").map((s: string) => s.trim()) + : undefined, + }); + + if (genResult.errors.length > 0) { + for (const e of genResult.errors) console.error(`error: ${e}`); + Deno.exit(ExitCode.DriftOrValidation); + } + + // 2. Render each generated stack + const allWarnings: string[] = []; + const results: Record = {}; + let hasUnresolved = false; + + for (const [stackName, yamlContent] of Object.entries(genResult.generated)) { + const parsed = parseYaml(yamlContent) as ComposeData; + const projectDir = repoRoot; // generated stacks live at repo root + + const result = await renderStack({ + data: parsed, + projectDir, + repoRoot, + strict, + }); + + allWarnings.push(...result.warnings); + if (result.hasUnresolved) hasUnresolved = true; + + results[stackName] = `# Rendered by stackctl render — do not edit manually.\n${ + stringifyYaml(result.data, { + indent: 2, + lineWidth: 120, + } as Record) + }`; + } + + // 3. Print warnings + for (const w of allWarnings) { + console.error(`warning: ${w}`); + } + + // 4. Output + if (dryRun) { + for (const [name, content] of Object.entries(results)) { + console.log(`# --- rendered: ${name} ---`); + console.log(content); + } + } else { + const outDir = resolve(repoRoot, renderOutputDir); + await ensureDir(outDir); + for (const [name, content] of Object.entries(results)) { + const outPath = join(outDir, `${name}.rendered.yml`); + await Deno.writeTextFile(outPath, content); + console.log(`wrote: ${outPath}`); + } + } + + if (strict && hasUnresolved) { + Deno.exit(ExitCode.DriftOrValidation); + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + Deno.exit(ExitCode.UnexpectedError); + } }); // --- up (issue #6) --- diff --git a/src/compose/generate.ts b/src/compose/generate.ts index 65432d3..06ec941 100644 --- a/src/compose/generate.ts +++ b/src/compose/generate.ts @@ -10,6 +10,7 @@ import { ensureDir } from "@std/fs/ensure-dir"; import { discoverComposeFiles } from "./discover.ts"; import { loadCompose, loadFragment } from "./load.ts"; import { composeDeepMerge } from "./merge.ts"; +import { applyOverrides } from "./override.ts"; import { applyLoggingDefaults, rewriteBindMountPaths, @@ -18,25 +19,49 @@ import { } from "./transform.ts"; import { collectAllNamedVolumes } from "./volumes.ts"; import type { ComposeData, ServiceDef } from "./types.ts"; +import type { OverrideEntry } from "../config/types.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- export interface GenerateOptions { + /** Stack names to generate (undefined = all discovered). */ stacks?: string[]; - configStackNames?: string[]; + /** Repository root path. */ repoRoot: string; + /** Output directory for generated stacks (default: /stacks). */ outputDir?: string; + /** Whether this is a dry run (no files written). */ dryRun?: boolean; - network?: string; + /** Optional override files to apply after source composition. */ + overrides?: (OverrideEntry | string)[]; } export interface GenerateResult { + /** Map of stack name -> YAML string content. */ generated: Record; + /** Warnings encountered (non-fatal). */ warnings: string[]; + /** Errors encountered (non-fatal — some stacks may still succeed). */ errors: string[]; + /** Files that were (or would be) written. */ files: string[]; } -const DEFAULT_NETWORK_NAME = "traefik-public"; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const NETWORK_NAME = "traefik-public"; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +/** + * Generate canonical Swarm stack files from per-service Compose sources. + */ export async function generateStacks( options: GenerateOptions, ): Promise { @@ -48,33 +73,32 @@ export async function generateStacks( files: [], }; + // 1. Discover all compose files const discovery = await discoverComposeFiles({ repoRoot: options.repoRoot }); for (const err of discovery.errors) { - result.warnings.push("Discovery error at " + err.path + ": " + err.message); + result.warnings.push(`Discovery error at ${err.path}: ${err.message}`); } - const targetStacks = options.stacks ?? - (options.configStackNames && options.configStackNames.length > 0 - ? options.configStackNames - : Object.keys(discovery.stacks)); + // 2. Determine which stacks to generate + const targetStacks = options.stacks ?? Object.keys(discovery.stacks); if (targetStacks.length === 0) { result.warnings.push("No stacks discovered"); return result; } + // 3. Ensure output directory exists if (!options.dryRun) { await ensureDir(outputDir); } - const network = options.network || DEFAULT_NETWORK_NAME; - + // 4. Generate each stack for (const stackName of targetStacks) { try { const composePaths = discovery.stacks[stackName]; if (!composePaths || composePaths.length === 0) { - result.errors.push('No compose files found for stack "' + stackName + '"'); + result.errors.push(`No compose files found for stack "${stackName}"`); continue; } @@ -82,12 +106,12 @@ export async function generateStacks( stackName, composePaths, options.repoRoot, - network, + options.overrides, ); result.generated[stackName] = output; - const outPath = join(outputDir, stackName + ".yml"); + const outPath = join(outputDir, `${stackName}.yml`); if (options.dryRun) { result.files.push(outPath); } else { @@ -96,7 +120,7 @@ export async function generateStacks( } } catch (err: unknown) { result.errors.push( - 'Stack "' + stackName + '": ' + (err instanceof Error ? err.message : String(err)), + `Stack "${stackName}": ${err instanceof Error ? err.message : String(err)}`, ); } } @@ -104,14 +128,17 @@ export async function generateStacks( return result; } +// --------------------------------------------------------------------------- +// Single-stack generation +// --------------------------------------------------------------------------- + async function generateSingleStack( _stackName: string, composePaths: string[], repoRoot: string, - network?: string, + overrides?: (OverrideEntry | string)[], ): Promise { - const networkName = network || DEFAULT_NETWORK_NAME; - + // 1. Load all compose files + fragments const sources = await Promise.all( composePaths.map(async (path) => { const composeDir = path.substring(0, path.lastIndexOf("/")); @@ -121,86 +148,65 @@ async function generateSingleStack( }), ); - const serviceDirMap = new Map(); - for (const src of sources) { - if (src.data.services) { - for (const svcName of Object.keys(src.data.services)) { - if (!serviceDirMap.has(svcName)) { - serviceDirMap.set(svcName, src.composeDir); - } - } - } - if (src.fragment.services) { - for (const svcName of Object.keys(src.fragment.services)) { - if (!serviceDirMap.has(svcName)) { - serviceDirMap.set(svcName, src.composeDir); - } - } - } - } - + // 2. Merge: compose data + fragment per-source, then merge all into one let merged: ComposeData = {}; for (const src of sources) { const combined = composeDeepMerge(src.data, src.fragment); merged = composeDeepMerge(merged, combined); } + // 2b. Apply override files (Docker Compose override merge semantics) + if (overrides?.length) { + merged = await applyOverrides(merged, overrides, repoRoot); + } + + // 3. Transform services if (merged.services) { const transformed: Record = {}; for (const [svcName, svc] of Object.entries(merged.services)) { - const svcDir = serviceDirMap.get(svcName) ?? sources[0]?.composeDir ?? ""; let t = stripComposeOnlyKeys(svc); t = applyLoggingDefaults(t); - t = rewriteEnvFile(t, svcDir, repoRoot); - t = rewriteBindMountPaths(t, svcDir, repoRoot); + t = rewriteEnvFile(t, sources[0]?.composeDir ?? "", repoRoot); + t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot); transformed[svcName] = t; } merged = { ...merged, services: transformed }; } + // 4. Collect named volumes const namedVolumes = collectAllNamedVolumes(merged.services); + // 5. Assemble output structure const output: Record = {}; + // Services if (merged.services && Object.keys(merged.services).length > 0) { - const svcs: Record = {}; - for (const key of Object.keys(merged.services).sort()) { - svcs[key] = merged.services[key]; - } - output.services = svcs; + output.services = merged.services; } + // Networks output.networks = { default: { - name: networkName, + name: NETWORK_NAME, external: true, }, }; + // Volumes (only if named volumes exist) if (namedVolumes.length > 0) { const volumes: Record = {}; - const topLevelVolumes = (merged.volumes ?? {}) as Record; - for (const name of namedVolumes.sort()) { - const existingDef = topLevelVolumes[name]; - if (existingDef && typeof existingDef === "object" && existingDef !== null) { - const def = { ...(existingDef as Record) }; - if (def.external === undefined) { - def.external = true; - } - volumes[name] = def; - } else { - volumes[name] = { external: true }; - } + for (const name of namedVolumes) { + volumes[name] = { external: true }; } output.volumes = volumes; } + // 6. Serialise to YAML const header = "# Generated by stackctl generate — do not edit manually.\n"; const body = stringifyYaml(output, { indent: 2, lineWidth: 120, - useAnchors: false, - sortKeys: true, - }); + noRefs: true, + } as Record); return header + body; } diff --git a/src/compose/generate_test.ts b/src/compose/generate_test.ts index 708f3ee..0a55e9c 100644 --- a/src/compose/generate_test.ts +++ b/src/compose/generate_test.ts @@ -10,11 +10,12 @@ async function makeTempDir(): Promise { } async function writeFile(dir: string, name: string, content: string) { - await Deno.writeTextFile(dir + "/" + name, content); + await Deno.writeTextFile(`${dir}/${name}`, content); } async function createFixture(repoRoot: string) { - const svcDir = repoRoot + "/services/web"; + // Create a service directory with compose + fragment + const svcDir = `${repoRoot}/services/web`; await Deno.mkdir(svcDir, { recursive: true }); await writeFile( @@ -53,21 +54,31 @@ async function createFixture(repoRoot: string) { } // --------------------------------------------------------------------------- -// Original tests +// Tests // --------------------------------------------------------------------------- Deno.test("generateStacks: single stack dry-run returns content", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); + const options: GenerateOptions = { + stacks: ["platform"], + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); assertEquals(result.errors, []); assertEquals(Object.keys(result.generated).length, 1); + assertEquals(result.files.length, 1); const content = result.generated["platform"]; + // Should have the header comment assertStringIncludes(content, "# Generated by stackctl generate"); + // Should contain the service assertStringIncludes(content, "web:"); + // Should have the network block assertStringIncludes(content, "traefik-public"); await Deno.remove(tmp, { recursive: true }); @@ -77,17 +88,21 @@ Deno.test("generateStacks: writes files when not dry-run", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const result = await generateStacks({ + const options: GenerateOptions = { stacks: ["platform"], repoRoot: tmp, - outputDir: tmp + "/stacks", + outputDir: `${tmp}/stacks`, dryRun: false, - }); + }; + + const result = await generateStacks(options); assertEquals(result.errors, []); - assertEquals(result.files[0], tmp + "/stacks/platform.yml"); + assertEquals(result.files.length, 1); + assertEquals(result.files[0], `${tmp}/stacks/platform.yml`); - const written = await Deno.readTextFile(tmp + "/stacks/platform.yml"); + // Verify file was actually written + const written = await Deno.readTextFile(`${tmp}/stacks/platform.yml`); assertStringIncludes(written, "# Generated by stackctl generate"); await Deno.remove(tmp, { recursive: true }); @@ -95,28 +110,49 @@ Deno.test("generateStacks: writes files when not dry-run", async () => { Deno.test("generateStacks: generates all stacks when no filter", async () => { const tmp = await makeTempDir(); - const svcA = tmp + "/services/api"; - const svcB = tmp + "/services/db"; + + // Create two stacks + const svcA = `${tmp}/services/api`; + const svcB = `${tmp}/services/db`; await Deno.mkdir(svcA, { recursive: true }); await Deno.mkdir(svcB, { recursive: true }); await writeFile( svcA, "docker-compose.yml", - ["x-stack: infra", "services:", " api:", " image: node:20"].join("\n"), + [ + "x-stack: infra", + "services:", + " api:", + " image: node:20", + ].join("\n"), ); + await writeFile( svcB, "docker-compose.yml", - ["x-stack: platform", "services:", " db:", " image: postgres:16"].join("\n"), + [ + "x-stack: platform", + "services:", + " db:", + " image: postgres:16", + ].join("\n"), ); - const result = await generateStacks({ repoRoot: tmp, dryRun: true }); + const options: GenerateOptions = { + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); assertEquals(result.errors, []); assertEquals(Object.keys(result.generated).length, 2); - assertStringIncludes(result.generated["infra"], "api:"); - assertStringIncludes(result.generated["platform"], "db:"); + + const content1 = result.generated["infra"]; + const content2 = result.generated["platform"]; + assertStringIncludes(content1, "api:"); + assertStringIncludes(content2, "db:"); await Deno.remove(tmp, { recursive: true }); }); @@ -125,7 +161,13 @@ Deno.test("generateStacks: reports error for nonexistent stack", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const result = await generateStacks({ stacks: ["nonexistent"], repoRoot: tmp, dryRun: true }); + const options: GenerateOptions = { + stacks: ["nonexistent"], + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); assertEquals(result.errors.length, 1); assertStringIncludes(result.errors[0], "nonexistent"); @@ -136,7 +178,12 @@ Deno.test("generateStacks: reports error for nonexistent stack", async () => { Deno.test("generateStacks: warnings for empty repo", async () => { const tmp = await makeTempDir(); - const result = await generateStacks({ repoRoot: tmp, dryRun: true }); + const options: GenerateOptions = { + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); assertEquals(result.warnings.length, 1); assertStringIncludes(result.warnings[0], "No stacks discovered"); @@ -175,7 +222,7 @@ Deno.test("generateStacks: output includes named volumes as external", async () Deno.test("generateStacks: services stripped of compose-only keys", async () => { const tmp = await makeTempDir(); - const svcDir = tmp + "/services/app"; + const svcDir = `${tmp}/services/app`; await Deno.mkdir(svcDir, { recursive: true }); await writeFile( @@ -197,204 +244,13 @@ Deno.test("generateStacks: services stripped of compose-only keys", async () => const result = await generateStacks({ stacks: ["infra"], repoRoot: tmp, dryRun: true }); const content = result.generated["infra"]; + // container_name, restart, build must NOT appear assertEquals(content.includes("container_name"), false); assertEquals(content.includes("restart:"), false); assertEquals(content.includes("build:"), false); + // deploy and image must stay assertStringIncludes(content, "deploy:"); assertStringIncludes(content, "image: alpine"); await Deno.remove(tmp, { recursive: true }); }); - -// --------------------------------------------------------------------------- -// Review-fix tests -// --------------------------------------------------------------------------- - -Deno.test("generateStacks: custom network name from config", async () => { - const tmp = await makeTempDir(); - await createFixture(tmp); - - const result = await generateStacks({ - stacks: ["platform"], - repoRoot: tmp, - dryRun: true, - network: "custom-internal", - }); - - const content = result.generated["platform"]; - assertStringIncludes(content, "custom-internal"); - assertEquals(content.includes("traefik-public"), false); - - await Deno.remove(tmp, { recursive: true }); -}); - -Deno.test("generateStacks: honors configStackNames ordering", async () => { - const tmp = await makeTempDir(); - const svcA = tmp + "/services/api"; - const svcB = tmp + "/services/db"; - await Deno.mkdir(svcA, { recursive: true }); - await Deno.mkdir(svcB, { recursive: true }); - - await writeFile( - svcA, - "docker-compose.yml", - ["x-stack: infra", "services:", " api:", " image: node:20"].join("\n"), - ); - await writeFile( - svcB, - "docker-compose.yml", - ["x-stack: platform", "services:", " db:", " image: postgres:16"].join("\n"), - ); - - const result = await generateStacks({ - configStackNames: ["platform", "infra"], - repoRoot: tmp, - dryRun: true, - }); - - assertEquals(result.errors, []); - assertEquals(Object.keys(result.generated), ["platform", "infra"]); - - await Deno.remove(tmp, { recursive: true }); -}); - -Deno.test("generateStacks: per-service composeDir path rewriting", async () => { - const tmp = await makeTempDir(); - const svcA = tmp + "/services/web"; - const svcB = tmp + "/services/api"; - await Deno.mkdir(svcA, { recursive: true }); - await Deno.mkdir(svcB, { recursive: true }); - - await writeFile( - svcA, - "docker-compose.yml", - [ - "x-stack: platform", - "services:", - " web:", - " image: nginx:alpine", - " env_file: .env", - " volumes:", - ' - "./html:/usr/share/nginx/html"', - ].join("\n"), - ); - await writeFile( - svcB, - "docker-compose.yml", - [ - "x-stack: platform", - "services:", - " api:", - " image: node:20", - " env_file: .env.prod", - " volumes:", - ' - "./config:/app/config"', - ].join("\n"), - ); - - const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); - - const content = result.generated["platform"]; - assertStringIncludes(content, "services/web/.env"); - assertStringIncludes(content, "services/api/.env.prod"); - assertStringIncludes(content, "services/web/html"); - assertStringIncludes(content, "services/api/config"); - - await Deno.remove(tmp, { recursive: true }); -}); - -Deno.test("generateStacks: preserves volume metadata from compose file", async () => { - const tmp = await makeTempDir(); - const svcDir = tmp + "/services/web"; - await Deno.mkdir(svcDir, { recursive: true }); - - await writeFile( - svcDir, - "docker-compose.yml", - [ - "x-stack: platform", - "", - "services:", - " web:", - " image: nginx:alpine", - " volumes:", - ' - "app-data:/var/lib/data"', - "", - "volumes:", - " app-data:", - " name: platform_data", - " driver: local", - " driver_opts:", - " type: none", - " device: /mnt/data", - " o: bind", - ].join("\n"), - ); - - const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); - - const content = result.generated["platform"]; - assertStringIncludes(content, "name: platform_data"); - assertStringIncludes(content, "driver: local"); - assertStringIncludes(content, "external: true"); - - await Deno.remove(tmp, { recursive: true }); -}); - -Deno.test("generateStacks: snapshot - generates deterministic output", async () => { - const tmp = await makeTempDir(); - const svcDir = tmp + "/services/app"; - await Deno.mkdir(svcDir, { recursive: true }); - await writeFile( - svcDir, - "docker-compose.yml", - [ - "x-stack: core", - "", - "services:", - " app:", - " image: nginx:alpine", - " ports:", - ' - "8080:80"', - " deploy:", - " replicas: 1", - ].join("\n"), - ); - - const outputDir = tmp + "/stacks"; - const result = await generateStacks({ - stacks: ["core"], - repoRoot: tmp, - outputDir, - dryRun: false, - }); - - assertEquals(result.errors, []); - assertEquals(result.files.length, 1); - - const generated = await Deno.readTextFile(outputDir + "/core.yml"); - - const expected = [ - "# Generated by stackctl generate — do not edit manually.", - "networks:", - " default:", - " external: true", - " name: traefik-public", - "services:", - " app:", - " deploy:", - " replicas: 1", - " image: 'nginx:alpine'", - " logging:", - " driver: local", - " options:", - " max-file: 3", - " max-size: 10m", - " ports:", - " - '8080:80'", - ].join("\n") + "\n"; - - assertEquals(generated, expected); - - await Deno.remove(tmp, { recursive: true }); -}); diff --git a/src/compose/mod.ts b/src/compose/mod.ts index 46512fd..b83fde5 100644 --- a/src/compose/mod.ts +++ b/src/compose/mod.ts @@ -5,6 +5,7 @@ export * from "./types.ts"; export * from "./discover.ts"; export * from "./load.ts"; export * from "./merge.ts"; +export * from "./override.ts"; export * from "./transform.ts"; export * from "./volumes.ts"; export { generateStacks } from "./generate.ts"; diff --git a/src/compose/override.ts b/src/compose/override.ts new file mode 100644 index 0000000..9c08bd8 --- /dev/null +++ b/src/compose/override.ts @@ -0,0 +1,134 @@ +/** + * Docker Compose override merge — distinct from fragment merge. + * + * Unlike fragment merge (composeDeepMerge, which REPLACES arrays), Docker + * Compose override merge follows the official Compose `-f` file semantics: + * + * - Scalars: override wins + * - Maps: recursive merge (override wins on conflicts) + * - Arrays: APPEND (not replace!) + * - Neither argument is mutated + * + * Ref: https://docs.docker.com/compose/multiple-compose-files/merge/ + */ +import type { ComposeData } from "./types.ts"; +import type { OverrideEntry } from "../config/types.ts"; +import { parse as parseYaml } from "@std/yaml"; +import { isAbsolute, resolve } from "@std/path"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Merge two compose structures using Docker Compose override rules. + * + * - Scalars: override wins + * - Maps: recursive merge (override wins on scalar conflicts) + * - Sequences: APPEND (unlike composeDeepMerge which replaces) + * - Neither argument mutated — returns a fresh object. + */ +export function composeOverrideMerge( + base: ComposeData, + override: ComposeData, +): ComposeData { + return deepOverrideRecord(base, override) as ComposeData; +} + +/** + * Load a YAML override file from path. + * + * Path can be absolute or relative to repoRoot. Throws a helpful error + * when the file is missing or contains invalid YAML. + */ +export async function loadOverrideFile( + path: string, + repoRoot: string, +): Promise { + const resolvedPath = isAbsolute(path) ? path : resolve(repoRoot, path); + + let raw: string; + try { + raw = await Deno.readTextFile(resolvedPath); + } catch (err: unknown) { + if (err instanceof Deno.errors.NotFound) { + throw new Error(`Override file not found: ${resolvedPath}`); + } + throw err; + } + + try { + const parsed = parseYaml(raw); + return (parsed ?? {}) as ComposeData; + } catch (err: unknown) { + throw new Error( + `Failed to parse override file ${resolvedPath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +/** + * Load override files and apply them sequentially to a base compose structure. + * + * Overrides are applied left-to-right (first entry is applied first). Each + * entry can be a plain file-path string or an {@link OverrideEntry} object. + * + * @param baseCompose - The base compose data to mutate (not mutated in place) + * @param overrides - Ordered list of override entries or file paths + * @param repoRoot - Repository root for resolving relative paths + * @returns The fully-merged compose data + */ +export async function applyOverrides( + baseCompose: ComposeData, + overrides: (OverrideEntry | string)[], + repoRoot: string, +): Promise { + let result = baseCompose; + + for (const entry of overrides) { + const path = typeof entry === "string" ? entry : entry.path; + const overrideData = await loadOverrideFile(path, repoRoot); + result = composeOverrideMerge(result, overrideData); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +function deepOverrideRecord( + base: Record, + override: Record, +): Record { + const result: Record = { ...base }; + + for (const key of Object.keys(override)) { + const overrideVal = override[key]; + const baseVal = base[key]; + + if (isPlainObject(overrideVal) && isPlainObject(baseVal)) { + // Recursive merge for objects (handles services/volumes/networks + // naturally — they are merged by key name) + result[key] = deepOverrideRecord( + baseVal as Record, + overrideVal as Record, + ); + } else if (Array.isArray(overrideVal) && Array.isArray(baseVal)) { + // Arrays are appended (Docker Compose override behaviour) + result[key] = [...baseVal, ...overrideVal]; + } else { + // Scalars (or type mismatch): override wins + result[key] = overrideVal; + } + } + + return result; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/compose/override_test.ts b/src/compose/override_test.ts new file mode 100644 index 0000000..19e533e --- /dev/null +++ b/src/compose/override_test.ts @@ -0,0 +1,437 @@ +/** + * Tests for Docker Compose override merge semantics. + */ +import { assertEquals, assertRejects } from "@std/assert"; +import { applyOverrides, composeOverrideMerge, loadOverrideFile } from "./override.ts"; +import type { ComposeData } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-override-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + await Deno.writeTextFile(`${dir}/${name}`, content); +} + +// --------------------------------------------------------------------------- +// composeOverrideMerge — basic merge rules +// --------------------------------------------------------------------------- + +Deno.test("composeOverrideMerge: scalar override", () => { + const base: ComposeData = { a: 1, b: 2 }; + const result = composeOverrideMerge(base, { b: 99 }); + assertEquals(result, { a: 1, b: 99 }); +}); + +Deno.test("composeOverrideMerge: dict recursive merge", () => { + const base: ComposeData = { top: { a: 1, b: 2, deep: { x: 10 } } }; + const override: ComposeData = { top: { b: 99, c: 3, deep: { y: 20 } } }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + top: { a: 1, b: 99, c: 3, deep: { x: 10, y: 20 } }, + }); +}); + +Deno.test("composeOverrideMerge: array append (not replace)", () => { + const base: ComposeData = { items: [1, 2, 3] }; + const override: ComposeData = { items: [4, 5] }; + const result = composeOverrideMerge(base, override); + // The key difference from fragment merge: arrays are APPENDED + assertEquals(result, { items: [1, 2, 3, 4, 5] }); +}); + +Deno.test("composeOverrideMerge: array from override only (no base array)", () => { + const base: ComposeData = {}; + const override: ComposeData = { items: [4, 5] }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { items: [4, 5] }); +}); + +Deno.test("composeOverrideMerge: empty override leaves base unchanged", () => { + const base: ComposeData = { a: 1, b: { c: 2 } }; + const result = composeOverrideMerge(base, {}); + assertEquals(result, { a: 1, b: { c: 2 } }); +}); + +Deno.test("composeOverrideMerge: empty base filled by override", () => { + const base: ComposeData = {}; + const result = composeOverrideMerge(base, { a: 1, b: [2, 3] }); + assertEquals(result, { a: 1, b: [2, 3] }); +}); + +Deno.test("composeOverrideMerge: adds new keys from override", () => { + const base: ComposeData = { existing: true }; + const result = composeOverrideMerge(base, { newKey: "hello" }); + assertEquals(result, { existing: true, newKey: "hello" }); +}); + +Deno.test("composeOverrideMerge: does not mutate base", () => { + const base: ComposeData = { a: 1 }; + const override: ComposeData = { b: 2 }; + composeOverrideMerge(base, override); + assertEquals(base, { a: 1 }); // base unchanged +}); + +Deno.test("composeOverrideMerge: does not mutate override", () => { + const base: ComposeData = { a: 1 }; + const override: ComposeData = { b: 2 }; + composeOverrideMerge(base, override); + assertEquals(override, { b: 2 }); // override unchanged +}); + +Deno.test("composeOverrideMerge: deeply nested with arrays appended", () => { + const base: ComposeData = { + a: { b: { names: ["old"], network: "old-net" } }, + }; + const override: ComposeData = { + a: { b: { names: ["new1", "new2"] } }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + a: { b: { names: ["old", "new1", "new2"], network: "old-net" } }, + }); +}); + +// --------------------------------------------------------------------------- +// composeOverrideMerge — compose-specific top-level keys +// --------------------------------------------------------------------------- + +Deno.test("composeOverrideMerge: service merge by name (new service added)", () => { + const base: ComposeData = { + services: { + app: { image: "nginx", ports: ["8080:80"] }, + }, + }; + const override: ComposeData = { + services: { + cache: { image: "redis", ports: ["6379:6379"] }, + }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + services: { + app: { image: "nginx", ports: ["8080:80"] }, + cache: { image: "redis", ports: ["6379:6379"] }, + }, + }); +}); + +Deno.test("composeOverrideMerge: service merge by name (existing service extended)", () => { + const base: ComposeData = { + services: { + app: { image: "nginx", ports: ["8080:80"] }, + }, + }; + const override: ComposeData = { + services: { + app: { image: "nginx:alpine", environment: { FOO: "bar" } }, + }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + services: { + app: { + image: "nginx:alpine", + ports: ["8080:80"], + environment: { FOO: "bar" }, + }, + }, + }); +}); + +Deno.test("composeOverrideMerge: service arrays are appended (ports, depends_on)", () => { + const base: ComposeData = { + services: { + app: { + image: "app", + ports: ["8080:80"], + depends_on: ["db"], + }, + }, + }; + const override: ComposeData = { + services: { + app: { + ports: ["8443:443"], + depends_on: ["cache"], + }, + }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + services: { + app: { + image: "app", + ports: ["8080:80", "8443:443"], + depends_on: ["db", "cache"], + }, + }, + }); +}); + +Deno.test("composeOverrideMerge: volume merge by name", () => { + const base: ComposeData = { + volumes: { + "app-data": { driver: "local" }, + }, + }; + const override: ComposeData = { + volumes: { + "cache-data": { driver: "local" }, + }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + volumes: { + "app-data": { driver: "local" }, + "cache-data": { driver: "local" }, + }, + }); +}); + +Deno.test("composeOverrideMerge: network merge by name", () => { + const base: ComposeData = { + networks: { + frontend: { driver: "overlay" }, + }, + }; + const override: ComposeData = { + networks: { + backend: { driver: "overlay" }, + }, + }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { + networks: { + frontend: { driver: "overlay" }, + backend: { driver: "overlay" }, + }, + }); +}); + +Deno.test("composeOverrideMerge: null override value overwrites base", () => { + const base: ComposeData = { a: 1 }; + const override: ComposeData = { a: null }; + const result = composeOverrideMerge(base, override); + assertEquals(result, { a: null }); +}); + +// --------------------------------------------------------------------------- +// loadOverrideFile +// --------------------------------------------------------------------------- + +Deno.test("loadOverrideFile: relative path resolved against repoRoot", async () => { + const tmpDir = await makeTempDir(); + try { + const overridePath = "overrides/prod.yml"; + const fullDir = `${tmpDir}/overrides`; + await Deno.mkdir(fullDir, { recursive: true }); + await writeFile(fullDir, "prod.yml", "services:\n app:\n image: prod-image\n"); + + const result = await loadOverrideFile(overridePath, tmpDir); + assertEquals(result, { + services: { app: { image: "prod-image" } }, + }); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadOverrideFile: absolute path used directly", async () => { + const tmpDir = await makeTempDir(); + try { + const absPath = `${tmpDir}/my-override.yml`; + await writeFile(tmpDir, "my-override.yml", "version: '3'\n"); + + const result = await loadOverrideFile(absPath, "/some/other/root"); + assertEquals(result, { version: "3" }); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadOverrideFile: missing file throws helpful error", async () => { + const tmpDir = await makeTempDir(); + try { + await assertRejects( + () => loadOverrideFile("nonexistent.yml", tmpDir), + Error, + "Override file not found", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadOverrideFile: empty file returns {}", async () => { + const tmpDir = await makeTempDir(); + try { + await writeFile(tmpDir, "empty.yml", ""); + + const result = await loadOverrideFile("empty.yml", tmpDir); + assertEquals(result, {}); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadOverrideFile: invalid YAML throws", async () => { + const tmpDir = await makeTempDir(); + try { + await writeFile(tmpDir, "bad.yml", "{{{invalid yaml!!!\n"); + + await assertRejects( + () => loadOverrideFile("bad.yml", tmpDir), + Error, + "Failed to parse override file", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +// --------------------------------------------------------------------------- +// applyOverrides — integration +// --------------------------------------------------------------------------- + +Deno.test("applyOverrides: single override file applied to base", async () => { + const tmpDir = await makeTempDir(); + try { + await writeFile( + tmpDir, + "override.yml", + [ + "services:", + " app:", + " environment:", + " DEBUG: 'true'", + "", + ].join("\n"), + ); + + const base: ComposeData = { + services: { + app: { image: "nginx", ports: ["8080:80"] }, + }, + }; + + const result = await applyOverrides(base, ["override.yml"], tmpDir); + + assertEquals(result, { + services: { + app: { + image: "nginx", + ports: ["8080:80"], + environment: { DEBUG: "true" }, + }, + }, + }); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("applyOverrides: multiple override files applied in order", async () => { + const tmpDir = await makeTempDir(); + try { + await writeFile( + tmpDir, + "first.yml", + [ + "services:", + " app:", + " environment:", + " FOO: bar", + "", + ].join("\n"), + ); + await writeFile( + tmpDir, + "second.yml", + [ + "services:", + " app:", + " environment:", + " FOO: baz", + " BAZ: qux", + " ports:", + ' - "8443:443"', + "", + ].join("\n"), + ); + + const base: ComposeData = { + services: { + app: { image: "app", ports: ["8080:80"] }, + }, + }; + + const result = await applyOverrides(base, ["first.yml", "second.yml"], tmpDir); + + assertEquals(result, { + services: { + app: { + image: "app", + ports: ["8080:80", "8443:443"], + environment: { FOO: "baz", BAZ: "qux" }, + }, + }, + }); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("applyOverrides: OverrideEntry with explicit path", async () => { + const tmpDir = await makeTempDir(); + try { + await writeFile( + tmpDir, + "my-override.yml", + [ + "services:", + " worker:", + " image: worker:latest", + "", + ].join("\n"), + ); + + const base: ComposeData = { services: {} }; + const result = await applyOverrides( + base, + [{ source: "explicit", path: `${tmpDir}/my-override.yml` }], + tmpDir, + ); + + assertEquals(result, { + services: { + worker: { image: "worker:latest" }, + }, + }); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("applyOverrides: missing override file throws", async () => { + const tmpDir = await makeTempDir(); + try { + await assertRejects( + () => applyOverrides({}, ["missing.yml"], tmpDir), + Error, + "Override file not found", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("applyOverrides: empty overrides array returns base unchanged", async () => { + const base: ComposeData = { a: 1 }; + const result = await applyOverrides(base, [], "/tmp"); + assertEquals(result, { a: 1 }); +}); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 573a2ac..47027c7 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -23,8 +23,4 @@ export const DEFAULT_CONFIG: StackctlConfig = { activeName: ".env", allowPlaintextProfiles: false, }, - overrides: { - autoDiscoverProfiles: true, - exclude: [], - }, }; diff --git a/src/config/init.ts b/src/config/init.ts index c19cd4d..5f671a6 100644 --- a/src/config/init.ts +++ b/src/config/init.ts @@ -1,12 +1,11 @@ /** - * Config initialisation - generates `.stackctl` template files. + * Config initialisation — generates `.stackctl` template files. * * Includes: * - A default template with extensive inline comments * - Optional auto-detection from docker-compose files * - Preset configurations (minimal, standard) * - Profile and force/dry-run support - * - .gitignore management integration */ import { exists } from "@std/fs"; import { parse as parseYaml } from "@std/yaml"; @@ -23,8 +22,6 @@ export interface InitOptions { preset?: string; /** Also create a profile config file. */ profile?: string; - /** Append .stackctl.local and .env entries to .gitignore. */ - writeGitignore?: boolean; /** Overwrite existing files. */ force?: boolean; /** Print what would be written without writing. */ @@ -85,11 +82,6 @@ env: # secrets: # encryptedFileName: ".env.enc" -# (Optional) Override file configuration -# overrides: -# autoDiscoverProfiles: true -# exclude: [] - # (Optional) Command-specific defaults # commands: # up: @@ -134,6 +126,12 @@ const PRESETS: Record = { /** * Generate one or more `.stackctl` configuration files. + * + * Behaviour: + * - Writes the base `.stackctl` file (with `--force` to overwrite). + * - If `--profile` is set, also writes `.stackctl.`. + * - `--detect` scans for docker-compose files to pre-populate values. + * - `--dry-run` prints what would happen without writing. */ export async function initConfig(options: InitOptions): Promise { const cwd = options.cwd ?? Deno.cwd(); @@ -155,47 +153,19 @@ export async function initConfig(options: InitOptions): Promise { template = await applyDetection(template, cwd); } - // Write base config (validated before write) + // Write base config const basePath = join(cwd, ".stackctl"); await writeConfigFile(basePath, template, options, result); - // Write profile config if requested (uses minimal profile template) + // Write profile config if requested if (options.profile) { - const profileContent = buildProfileTemplate(options.profile); const profilePath = join(cwd, `.stackctl.${options.profile}`); - await writeConfigFile(profilePath, profileContent, options, result); - } - - // Handle --write-gitignore - if (options.writeGitignore) { - await appendGitignore(cwd, result); + await writeConfigFile(profilePath, template, options, result); } return result; } -// --------------------------------------------------------------------------- -// Profile template -// --------------------------------------------------------------------------- - -function buildProfileTemplate(profileName: string): string { - return `# stackctl profile overlay for '${profileName}' -# Generated by \`stackctl init --profile ${profileName}\` - -profile: "${profileName}" - -# (Optional) Override any base config fields below. -# Only specify the sections you want to override - profile files -# are merged on top of the base .stackctl config. -# -# Example: -# stack: -# network: "dev-net" -# env: -# activeName: ".env.dev" -`; -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -215,24 +185,6 @@ async function writeConfigFile( return; } - // Validate generated template YAML structure before writing - try { - const parsed = parseYaml(content) as Record; - if (!parsed || typeof parsed !== "object") { - result.errors.push( - `Generated config at ${path} resolves to invalid structure`, - ); - return; - } - } catch (err: unknown) { - result.errors.push( - `Failed to parse generated config (${path}): ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return; - } - if (options.dryRun) { result.written.push(path); return; @@ -249,59 +201,15 @@ async function writeConfigFile( } /** - * Append stackctl-specific entries to .gitignore. - */ -export async function appendGitignore( - cwd: string, - result?: InitResult, -): Promise { - const gitignorePath = join(cwd, ".gitignore"); - const entries = [ - "# stackctl generated files", - ".stackctl.local", - ".stackctl.local.*", - ".env", - ".env.*", - "!.env.example", - ]; - - let existing = ""; - if (await exists(gitignorePath)) { - existing = await Deno.readTextFile(gitignorePath); - if (!existing.endsWith("\n")) existing += "\n"; - } - - const newEntries = entries.filter((e) => !existing.includes(e)); - if (newEntries.length === 0) { - console.log(".gitignore already up to date"); - return; - } - - const toAppend = (existing ? "\n" : "") + newEntries.join("\n") + "\n"; - await Deno.writeTextFile(gitignorePath, existing + toAppend); - console.log(`updated: ${gitignorePath}`); - - if (result) { - result.written.push(gitignorePath); - } -} - -/** - * Scan the working directory for docker-compose files, subdirectories, - * fragments, secret files, and env files to pre-populate config values. + * Scan the working directory for docker-compose files and + * attempt to pre-populate stack names and network from them. */ async function applyDetection(template: string, cwd: string): Promise { + // Scan for docker-compose.yml / .yaml files in the first level of cwd const composeFiles: string[] = []; - const fragments: string[] = []; - let hasSops = false; - let hasAgeKey = false; - let hasEnvExample = false; - let hasEnvEnc = false; - for await (const entry of Deno.readDir(cwd)) { - const name = entry.name; - if (entry.isFile) { + const name = entry.name; if ( name === "docker-compose.yml" || name === "docker-compose.yaml" || @@ -312,63 +220,13 @@ async function applyDetection(template: string, cwd: string): Promise { ) { composeFiles.push(join(cwd, name)); } - - if (name.toLowerCase().includes("fragment")) { - fragments.push(name); - } - - if (name === ".sops.yaml") hasSops = true; - if (name === ".age-key") hasAgeKey = true; - if (name === ".env.example") hasEnvExample = true; - if (name === ".env.enc") hasEnvEnc = true; - } - - // Scan one level deep in subdirectories - if (entry.isDirectory) { - try { - const subDir = join(cwd, name); - for await (const subEntry of Deno.readDir(subDir)) { - if (subEntry.isFile) { - const subName = subEntry.name; - if ( - subName === "docker-compose.yml" || - subName === "docker-compose.yaml" || - subName === "compose.yml" || - subName === "compose.yaml" || - (subName.startsWith("docker-compose.") && - (subName.endsWith(".yml") || subName.endsWith(".yaml"))) - ) { - composeFiles.push(join(subDir, subName)); - } - if (subName.toLowerCase().includes("fragment")) { - fragments.push(join(name, subName)); - } - } - } - } catch { - // Skip unreadable subdirectories - } - } - - // Scan existing stacks/ directory - if (entry.isDirectory && name === "stacks") { - try { - const stacksDir = join(cwd, name); - for await (const stackEntry of Deno.readDir(stacksDir)) { - if (stackEntry.isFile) { - fragments.push(join("stacks", stackEntry.name)); - } - } - } catch { - // Skip unreadable stacks dir - } } } - // Phase 2: Parse compose files + if (composeFiles.length === 0) return template; + + // Try to parse the first compose file to extract useful info const stackNames: string[] = []; - const xStackGroups = new Map(); - let composeStackKey = ""; let network = ""; for (const file of composeFiles) { @@ -376,35 +234,19 @@ async function applyDetection(template: string, cwd: string): Promise { const raw = await Deno.readTextFile(file); const parsed = parseYaml(raw) as Record; - if (parsed?.["x-stack"] && !composeStackKey) { - composeStackKey = parsed["x-stack"] as string; - } - if (parsed?.services && typeof parsed.services === "object") { - const services = parsed.services as Record< - string, - Record - >; - for (const [svcName, svcDef] of Object.entries(services)) { + for (const svcName of Object.keys(parsed.services as Record)) { if (!stackNames.includes(svcName)) { stackNames.push(svcName); } - - const xStack = svcDef?.["x-stack"] as string | undefined; - if (xStack) { - const existing = xStackGroups.get(xStack) ?? []; - if (!existing.includes(svcName)) existing.push(svcName); - xStackGroups.set(xStack, existing); - } } } if (!network && parsed?.networks && typeof parsed.networks === "object") { const nets = parsed.networks as Record; const keys = Object.keys(nets); - const custom = keys.find( - (k) => k !== "default" && k !== "bridge" && k !== "host", - ); + // Exclude default networks + const custom = keys.find((k) => k !== "default" && k !== "bridge" && k !== "host"); if (custom) network = custom; else if (keys.length > 0) { const first = keys[0]; @@ -412,44 +254,31 @@ async function applyDetection(template: string, cwd: string): Promise { } } } catch { - // Skip unparseable files + // Skip unparseable files during detection } } - if ( - composeFiles.length === 0 && fragments.length === 0 && !hasEnvExample && - !hasEnvEnc - ) { - return template; - } - - // Phase 3: Inject detected values + // Inject detected values into the template string let result = template; - const finalStackNames = xStackGroups.size > 0 ? Array.from(xStackGroups.keys()) : stackNames; - - if (finalStackNames.length > 0) { - const namesYaml = finalStackNames.map((n) => ` - "${n}"`).join("\n"); + if (stackNames.length > 0) { + const namesYaml = stackNames.map((n) => ` - "${n}"`).join("\n"); + // Replace the example name in the names array result = result.replace( - /(\n)(\s+)names:\n(\s+- "example".*\n)+/, - `$1$2names:\n${namesYaml}\n`, + /\n\s+names:\n(\s+- "example".*\n)+/, + `\n names:\n${namesYaml}\n`, ); } if (network) { + // Replace the empty network value result = result.replace( - /(\n)(\s+)network: ""/, - `$1$2network: "${network}"`, - ); - } - - if (composeStackKey && composeStackKey !== "x-stack") { - result = result.replace( - /composeStackKey: "x-stack"/, - `composeStackKey: "${composeStackKey}"`, + /\n\s+network: ""/, + `\n network: "${network}"`, ); } + // Derive project name from directory const dirName = basename(cwd); if (dirName && dirName !== "." && dirName !== "/") { result = result.replace( @@ -458,22 +287,5 @@ async function applyDetection(template: string, cwd: string): Promise { ); } - const discoveries: string[] = []; - if (fragments.length > 0) { - discoveries.push( - `# Detected fragments:\n# ${fragments.join("\n# ")}`, - ); - } - if (hasSops) discoveries.push("# Detected: .sops.yaml (secrets tooling)"); - if (hasAgeKey) discoveries.push("# Detected: .age-key (age identity)"); - if (hasEnvExample) discoveries.push("# Detected: .env.example"); - if (hasEnvEnc) { - discoveries.push("# Detected: .env.enc (encrypted env file)"); - } - - if (discoveries.length > 0) { - result = result.trimEnd() + "\n\n" + discoveries.join("\n") + "\n"; - } - return result; } diff --git a/src/config/load.ts b/src/config/load.ts index 9b7822e..d753e21 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -105,7 +105,7 @@ export async function discoverConfigFiles( export async function resolveConfig( options?: ResolveOptions, ): Promise { - let profile = options?.profile ?? Deno.env.get("STACKCTL_PROFILE"); + const profile = options?.profile ?? Deno.env.get("STACKCTL_PROFILE"); const cwd = options?.cwd ?? Deno.cwd(); // Acquire base config (discovery or explicit path) @@ -135,9 +135,6 @@ export async function resolveConfig( } } else { discovery = await discoverConfigFiles({ cwd, profile }); - if (!discovery) { - throw new Error("No .stackctl config file found. Run `stackctl init` to create one."); - } } // Start with defaults @@ -151,48 +148,6 @@ export async function resolveConfig( const baseConfig = await loadConfigFile(discovery.configPath); merged = mergeConfig(merged, baseConfig); - // Determine profile from defaultProfile if not already set - if (!profile && merged.defaultProfile) { - profile = merged.defaultProfile; - } - - // If we now have a profile, discover .stackctl. in a second pass - if (profile && !discovery.profilePath) { - const baseDir = dirname(discovery.configPath); - const profilePath = join(baseDir, `.stackctl.${profile}`); - if (await exists(profilePath)) { - discovery.profilePath = profilePath; - } - const localProfilePath = join(baseDir, `.stackctl.local.${profile}`); - if (await exists(localProfilePath)) { - discovery.localProfilePath = localProfilePath; - } - } - - // Check for ambiguity: no profile, multiple .stackctl.* files, no defaultProfile - if (!profile) { - const baseDir = dirname(discovery.configPath); - const profileFiles = await findProfileFiles(baseDir); - if (profileFiles.length > 1) { - const names = profileFiles.map((f: string) => f.replace(".stackctl.", "")).join(", "); - throw new Error( - `Ambiguous profile detection: found multiple profile files (${names}). ` + - `Either set a defaultProfile in .stackctl or specify --profile .`, - ); - } else if (profileFiles.length === 1) { - const detected = profileFiles[0].replace(".stackctl.", ""); - profile = detected; - const profilePath = join(baseDir, `.stackctl.${detected}`); - if (await exists(profilePath)) { - discovery.profilePath = profilePath; - } - const localProfilePath = join(baseDir, `.stackctl.local.${detected}`); - if (await exists(localProfilePath)) { - discovery.localProfilePath = localProfilePath; - } - } - } - // Layer 3: profile if (discovery.profilePath) { profileConfig = await loadConfigFile(discovery.profilePath); @@ -260,29 +215,11 @@ async function walkUpFind(startDir: string, target: string): Promise { - const profileFiles: string[] = []; - try { - for await (const entry of Deno.readDir(dir)) { - if (!entry.isFile) continue; - const name = entry.name; - if (!name.startsWith(".stackctl.")) continue; - if (name === ".stackctl") continue; - if (name === ".stackctl.local") continue; - if (name.startsWith(".stackctl.local.")) continue; - profileFiles.push(name); - } - } catch { - // Directory unreadable, return empty - } - return profileFiles; -} - /** Find repo root by looking for .git upwards, falling back to baseDir. */ async function findRepoRoot(cwd: string, baseDir: string): Promise { const gitDir = await walkUpFind(cwd, ".git"); if (gitDir) { + // gitDir is the path to the .git file/dir, repoRoot is its parent return dirname(gitDir); } return baseDir; diff --git a/src/config/types.ts b/src/config/types.ts index 0223ba2..de9cf37 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -23,7 +23,6 @@ export interface StackctlConfig { secrets?: SecretsConfig; /** Command-specific defaults. */ commands?: CommandsConfig; - overrides?: OverridesConfig; } export interface StackConfig { @@ -83,11 +82,6 @@ export interface ReloadConfig { forceServiceUpdate?: boolean; } -export interface OverridesConfig { - autoDiscoverProfiles?: boolean; - exclude?: string[]; -} - /** A resolved profile configuration — partial config that overlays base config. */ export type ProfileConfig = Partial; diff --git a/src/render/mod.ts b/src/render/mod.ts new file mode 100644 index 0000000..e0a4ff4 --- /dev/null +++ b/src/render/mod.ts @@ -0,0 +1,588 @@ +/** + * Render/Env Interpolation Module — Issue #5 + * + * Ported from tools/render_compose.py (AniTrend/local-stack). + * + * Pipeline position: Generate -> Override -> Render -> Deploy + */ +import type { ComposeData, ServiceDef, VolumeMount } from "../compose/types.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface RenderOptions { + /** Parsed compose data to render. */ + data: ComposeData; + /** Directory of the stack/compose file (for path resolution). */ + projectDir: string; + /** Repository root for resolving service env_file paths. */ + repoRoot: string; + /** Whether to fail on unresolved variables (default: false). */ + strict?: boolean; +} + +export interface RenderResult { + /** The rendered compose data. */ + data: ComposeData; + /** Warnings encountered (e.g., missing env files, unresolved vars in non-strict). */ + warnings: string[]; + /** Whether any unresolved variables remain (only populated in strict mode). */ + hasUnresolved?: boolean; +} + +// --------------------------------------------------------------------------- +// Regex patterns +// --------------------------------------------------------------------------- + +/** + * Matches ${VAR}, ${VAR-default}, ${VAR:-default}. + * + * Groups: + * name - variable name + * sep - separator: "-" or ":-" + * default - default value + */ +const INTERP_RE = /\$\{(?[A-Za-z_][A-Za-z0-9_]*)\s*(?:(?:-|-)\s*(?[^}]*))?\}/g; + +/** + * Matches plain $VAR (no braces, not preceded by another $). + */ +const PLAIN_VAR_RE = /(?[A-Za-z_][A-Za-z0-9_]*)/g; + +/** + * Matches any leftover ${VAR} patterns (for strict-mode check). + */ +const UNRESOLVED_RE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/; + +/** + * Matches a relative path (starts with ./ or ../). + */ +const REL_PATH_RE = /^\.\.?\//; + +// --------------------------------------------------------------------------- +// parseEnvFile +// --------------------------------------------------------------------------- + +/** + * Parse a .env file (simple KEY=VALUE lines) into a dict. + * + * - Ignores comments (#) and blank lines. + * - Supports `export KEY=VALUE` syntax. + * - Strips surrounding quotes from values. + * - Throws if the file cannot be read. + */ +export async function parseEnvFile(path: string): Promise> { + const result: Record = {}; + let raw: string; + + try { + raw = await Deno.readTextFile(path); + } catch (err: unknown) { + if (err instanceof Deno.errors.NotFound) { + throw new Error(`Env file not found: ${path}`); + } + throw err; + } + + for (const line of raw.split("\n")) { + // Trim and skip blank/comment lines + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + + // Support "export KEY=VALUE" syntax + let effective = trimmed; + if (effective.startsWith("export ")) { + effective = effective.slice(7).trim(); + } + + // Find first "=" + const eqIdx = effective.indexOf("="); + if (eqIdx === -1) continue; // skip malformed lines + + const key = effective.slice(0, eqIdx).trim(); + let value = effective.slice(eqIdx + 1).trim(); + + // Strip surrounding quotes (single or double) + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (key.length > 0) { + result[key] = value; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// resolveEnvPath +// --------------------------------------------------------------------------- + +/** + * Resolve a service env_file path, trying projectDir first then repoRoot. + * + * - Absolute paths are returned as-is. + * - Relative paths are resolved against projectDir first; if not found, repoRoot. + */ +export function resolveEnvPath( + relPath: string, + projectDir: string, + repoRoot: string, +): string { + if (relPath.startsWith("/")) return relPath; + + // Try projectDir first + const fromProject = `${projectDir}/${relPath}`; + try { + Deno.statSync(fromProject); + return fromProject; + } catch { + // Not found at projectDir, fall through to repoRoot + } + + // Fallback to repoRoot + return `${repoRoot}/${relPath}`; +} + +// --------------------------------------------------------------------------- +// absolutizeServicePaths +// --------------------------------------------------------------------------- + +/** + * Rewrite relative env_file and bind-mount paths to absolute paths + * so rendered YAML works from a different output directory. + * + * Does NOT mutate the input service. + */ +export function absolutizeServicePaths( + service: ServiceDef, + projectDir: string, + repoRoot: string, +): ServiceDef { + const result: ServiceDef = { ...service }; + + // Absolutize env_file + if (result.env_file !== undefined) { + if (Array.isArray(result.env_file)) { + result.env_file = result.env_file.map((p) => absolutizePath(p, projectDir, repoRoot)); + } else { + result.env_file = absolutizePath( + result.env_file as string, + projectDir, + repoRoot, + ); + } + } + + // Absolutize bind-mount paths in volumes + if (result.volumes !== undefined) { + result.volumes = result.volumes.map((vm) => absolutizeVolumeMount(vm, projectDir)); + } + + return result; +} + +/** + * Make a path absolute by resolving relative to projectDir. + * Absolute paths and paths with variables are left as-is. + */ +function absolutizePath( + path: string, + projectDir: string, + repoRoot: string, +): string { + if (path.startsWith("/")) return path; + if (!REL_PATH_RE.test(path)) { + // Might be repo-relative (e.g. "services/app/.env") + // Check if exists relative to repoRoot + return resolveEnvPath(path, projectDir, repoRoot); + } + // Resolve ./ or ../ + return resolvePath(projectDir, path); +} + +/** + * Resolve a path without checking existence. + */ +function resolvePath(base: string, rel: string): string { + const parts = rel.split("/"); + const baseParts = base.split("/").filter(Boolean); + + for (const part of parts) { + if (part === "..") { + baseParts.pop(); + } else if (part !== ".") { + baseParts.push(part); + } + } + + return "/" + baseParts.join("/"); +} + +/** + * Absolutize a single volume mount entry. + * Named volumes are left unchanged. + */ +function absolutizeVolumeMount( + mount: VolumeMount, + projectDir: string, +): VolumeMount { + if (typeof mount === "string") { + return absolutizeBindMountString(mount, projectDir); + } + + // Long-form dict + const type = mount.type; + if (type === "volume") return mount; // named volumes — skip + + // bind or missing type (treated as bind) + if ( + typeof mount.source === "string" && + REL_PATH_RE.test(mount.source) + ) { + return { + ...mount, + source: resolvePath(projectDir, mount.source), + }; + } + return mount; +} + +/** + * Absolutize a short-form volume mount string. + * + * Format: `[source:]target[:mode]` + * If the source component is a relative path, absolutize it. + */ +function absolutizeBindMountString( + mount: string, + projectDir: string, +): string { + const parts = mount.split(":"); + if (parts.length >= 2) { + const source = parts[0]; + // If source is a relative bind mount path, absolutize + if (REL_PATH_RE.test(source)) { + parts[0] = resolvePath(projectDir, source); + return parts.join(":"); + } + } + return mount; +} + +// --------------------------------------------------------------------------- +// coerceEnvironmentToDict +// --------------------------------------------------------------------------- + +/** + * Normalize service.environment to a dict of strings. + * + * Supports both: + * - Mapping form: { KEY: value } + * - List form: ["KEY=VALUE", ...] + * + * Bare keys (no "=" in list form) are skipped. + * Returns empty dict for null/undefined/missing. + */ +export function coerceEnvironmentToDict(env: unknown): Record { + if (env === undefined || env === null) return {}; + + if (Array.isArray(env)) { + const result: Record = {}; + for (const item of env) { + if (typeof item !== "string") continue; + const eqIdx = item.indexOf("="); + if (eqIdx === -1) continue; // bare key — skip + const key = item.slice(0, eqIdx).trim(); + const value = item.slice(eqIdx + 1); + if (key.length > 0) result[key] = value; + } + return result; + } + + if (typeof env === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(env as Record)) { + if (value !== undefined && value !== null) { + result[key] = String(value); + } + } + return result; + } + + return {}; +} + +// --------------------------------------------------------------------------- +// buildServiceScope +// --------------------------------------------------------------------------- + +/** + * Build the variable scope for a service, layering: + * 1. Shell environment (base) + * 2. Per-service env_file(s) (in order) + * 3. service.environment (highest priority) + */ +export async function buildServiceScope( + service: ServiceDef, + baseEnv: Record, + projectDir: string, + repoRoot: string, +): Promise> { + // Start with shell env + const scope: Record = { ...baseEnv }; + + // Layer env_file(s) + const envFiles = service.env_file; + if (envFiles) { + const files = Array.isArray(envFiles) ? envFiles : [envFiles]; + for (const f of files) { + const resolved = resolveEnvPath(f, projectDir, repoRoot); + try { + const vars = await parseEnvFile(resolved); + Object.assign(scope, vars); + } catch { + // Missing env file — silently skip (warning emitted at renderStack level) + } + } + } + + // Layer service.environment (highest priority) + const serviceEnv = service.environment; + if (serviceEnv !== undefined) { + const envDict = coerceEnvironmentToDict(serviceEnv); + Object.assign(scope, envDict); + } + + return scope; +} + +// --------------------------------------------------------------------------- +// substitute +// --------------------------------------------------------------------------- + +/** + * Perform ${VAR}, ${VAR-default}, ${VAR:-default} substitution on a single string. + * + * Rules: + * ${VAR} — use VAR if defined, else leave as-is + * ${VAR-default} — use VAR if defined (empty counts as defined!), else 'default' + * ${VAR:-default} — use VAR if defined AND non-empty, else 'default' + * $VAR — plain unbraced form (same as ${VAR}) + * $$ — preserved as $ (handled by negative lookbehind in PLAIN_VAR_RE) + * + * Unresolved variables are left as-is. + */ +export function substitute(s: string, vars: Record): string { + // Step 1: resolve ${VAR...} patterns + let result = s.replace( + INTERP_RE, + (_match, name: string, sep: string | undefined, defaultValue: string | undefined) => { + const rawVar = name; + const hasVar = rawVar in vars; + const varValue = hasVar ? vars[rawVar] : undefined; + + if (!hasVar) { + // Variable not defined at all + if (sep === undefined) { + // ${VAR} — leave as-is + return _match; + } + // ${VAR-default} or ${VAR:-default} — use default + return defaultValue ?? ""; + } + + if (sep === undefined) { + // ${VAR} — use value + return varValue ?? ""; + } + + if (sep === "-") { + // ${VAR-default} — use var if defined (even empty) + return varValue ?? ""; + } + + // sep === ":-" — use var if defined AND non-empty + if (varValue !== undefined && varValue !== "") { + return varValue; + } + return defaultValue ?? ""; + }, + ); + + // Step 2: resolve plain $VAR patterns + result = result.replace(PLAIN_VAR_RE, (_match, name: string) => { + if (name in vars) { + return vars[name] ?? ""; + } + return _match; + }); + + return result; +} + +// --------------------------------------------------------------------------- +// deepInterpolate +// --------------------------------------------------------------------------- + +/** + * Recursively interpolate all string values in a value (string/dict/list/scalar). + * + * Non-string values (numbers, booleans, null) are passed through unchanged. + * Objects and arrays are recursed into. + */ +export function deepInterpolate( + obj: unknown, + vars: Record, +): unknown { + if (typeof obj === "string") { + return substitute(obj, vars); + } + + if (Array.isArray(obj)) { + return obj.map((item) => deepInterpolate(item, vars)); + } + + if (typeof obj === "object" && obj !== null) { + const result: Record = {}; + for ( + const [key, value] of Object.entries( + obj as Record, + ) + ) { + result[key] = deepInterpolate(value, vars); + } + return result; + } + + // Numbers, booleans, null — pass through + return obj; +} + +// --------------------------------------------------------------------------- +// renderStack +// --------------------------------------------------------------------------- + +/** + * Render a stack/compose data structure with per-service env interpolation. + * + * This is the main entry point equivalent to Python's render_compose(). + * + * Steps: + * 1. Get shell environment as baseline. + * 2. For each service, build the variable scope: + * shell env -> env_file(s) -> service.environment + * 3. Recursively interpolate all string values in the service. + * 4. Absolutize relative paths so rendered YAML works from any directory. + * 5. Collect warnings for missing env files and unresolved variables. + * 6. In strict mode: check for leftover ${VAR} patterns and fail if found. + */ +export async function renderStack(options: RenderOptions): Promise { + const { data, projectDir, repoRoot, strict = false } = options; + const warnings: string[] = []; + + // 1. Get shell environment + const shellEnv = Deno.env.toObject(); + + // 2. Clone the data (shallow) to avoid mutating input + const rendered: ComposeData = { ...data }; + + // 3. Process services + if (rendered.services) { + const services: Record = {}; + + for (const [svcName, svc] of Object.entries(rendered.services)) { + // Build variable scope for this service + let scope: Record; + try { + scope = await buildServiceScope(svc, shellEnv, projectDir, repoRoot); + } catch (err: unknown) { + warnings.push( + `Service "${svcName}": failed to build env scope: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + scope = { ...shellEnv }; + } + + // Check for missing env files and warn + const envFiles = svc.env_file; + if (envFiles) { + const files = Array.isArray(envFiles) ? envFiles : [envFiles]; + for (const f of files) { + const resolved = resolveEnvPath(f, projectDir, repoRoot); + try { + await Deno.stat(resolved); + } catch { + warnings.push( + `Service "${svcName}" references env_file "${f}" but file not found at "${resolved}"`, + ); + } + } + } + + // Deep interpolate the service + const interpolated = deepInterpolate(svc, scope) as ServiceDef; + + // Absolutize paths + const absolutized = absolutizeServicePaths( + interpolated, + projectDir, + repoRoot, + ); + + services[svcName] = absolutized; + } + + rendered.services = services; + } + + // 4. Interpolate top-level keys other than services (volumes, networks, etc.) + const topLevelKeys = Object.keys(rendered).filter((k) => k !== "services"); + for (const key of topLevelKeys) { + const shellScope = { ...shellEnv }; + rendered[key] = deepInterpolate(rendered[key], shellScope); + } + + // 5. Strict mode check + let hasUnresolved: boolean | undefined; + if (strict) { + // Stringify the rendered data and check for leftover ${VAR} + // We need to check the service values since those are the ones that should be resolved + hasUnresolved = false; + for (const [, svc] of Object.entries(rendered.services ?? {})) { + const svcJson = JSON.stringify(svc); + if (UNRESOLVED_RE.test(svcJson)) { + hasUnresolved = true; + + // Find which variables remain unresolved for the warning + const matches = svcJson.match( + /\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, + ); + if (matches) { + for (const m of new Set(matches)) { + warnings.push(`Unresolved variable in strict mode: ${m}`); + } + } + } + } + } else { + // Non-strict: just warn about unresolvable patterns + for (const [, svc] of Object.entries(rendered.services ?? {})) { + const svcJson = JSON.stringify(svc); + const matches = svcJson.match(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g); + if (matches) { + for (const m of new Set(matches)) { + warnings.push(`Unresolved variable (left as-is): ${m}`); + } + } + } + } + + return { data: rendered, warnings, hasUnresolved }; +} diff --git a/src/render/render_test.ts b/src/render/render_test.ts new file mode 100644 index 0000000..06ec9a1 --- /dev/null +++ b/src/render/render_test.ts @@ -0,0 +1,493 @@ +/** + * Tests for render/compose env interpolation — Issue #5. + */ +import { assertEquals, assertNotEquals, assertRejects, assertStringIncludes } from "@std/assert"; +import { + absolutizeServicePaths, + buildServiceScope, + coerceEnvironmentToDict, + deepInterpolate, + parseEnvFile, + renderStack, + resolveEnvPath, + substitute, +} from "./mod.ts"; +import type { ServiceDef } from "../compose/types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-render-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + await Deno.writeTextFile(`${dir}/${name}`, content); +} + +// --------------------------------------------------------------------------- +// parseEnvFile +// --------------------------------------------------------------------------- + +Deno.test("parseEnvFile: simple KEY=VALUE", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "FOO=bar\nBAZ=qux\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar", BAZ: "qux" }); +}); + +Deno.test("parseEnvFile: lines with comments", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "# this is a comment\nFOO=bar\n# another comment\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar" }); +}); + +Deno.test("parseEnvFile: blank lines", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "\n\nFOO=bar\n\nBAZ=qux\n\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar", BAZ: "qux" }); +}); + +Deno.test("parseEnvFile: export prefix", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "export FOO=bar\nexport BAZ=qux\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar", BAZ: "qux" }); +}); + +Deno.test("parseEnvFile: quoted values", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "FOO=\"bar baz\"\nBAZ='qux quux'\nPLAIN=naked\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar baz", BAZ: "qux quux", PLAIN: "naked" }); +}); + +Deno.test("parseEnvFile: file not found throws", async () => { + const dir = await makeTempDir(); + await assertRejects( + () => parseEnvFile(`${dir}/nonexistent.env`), + Error, + "Env file not found", + ); +}); + +Deno.test("parseEnvFile: malformed lines skipped", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "NO_EQUALS\nFOO=bar\n"); + const result = await parseEnvFile(`${dir}/.env`); + assertEquals(result, { FOO: "bar" }); +}); + +// --------------------------------------------------------------------------- +// resolveEnvPath +// --------------------------------------------------------------------------- + +Deno.test("resolveEnvPath: absolute path returned as-is", () => { + const result = resolveEnvPath("/etc/hosts", "/project", "/repo"); + assertEquals(result, "/etc/hosts"); +}); + +Deno.test("resolveEnvPath: relative path resolved against projectDir", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "FOO=bar\n"); + // File is at /.env, projectDir is + const result = resolveEnvPath(".env", dir, "/never"); + assertEquals(result, `${dir}/.env`); +}); + +Deno.test("resolveEnvPath: relative path falls back to repoRoot", () => { + const result = resolveEnvPath(".env", "/nonexistent", "/repo"); + assertEquals(result, "/repo/.env"); +}); + +Deno.test("resolveEnvPath: ./ prefix handling", () => { + const result = resolveEnvPath("./config.env", "/nonexistent", "/repo"); + assertEquals(result, "/repo/./config.env"); +}); + +// --------------------------------------------------------------------------- +// absolutizeServicePaths +// --------------------------------------------------------------------------- + +Deno.test("absolutizeServicePaths: env_file string made absolute", () => { + const svc: ServiceDef = { env_file: "./.env", image: "nginx" }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + assertNotEquals(result.env_file, "./.env"); + assertEquals(typeof result.env_file, "string"); + assertEquals(result.env_file, "/project/app/.env"); +}); + +Deno.test("absolutizeServicePaths: env_file list made absolute", () => { + const svc: ServiceDef = { env_file: ["./.env", "./.env.prod"] }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + assertEquals(Array.isArray(result.env_file), true); + assertEquals(result.env_file, ["/project/app/.env", "/project/app/.env.prod"]); +}); + +Deno.test("absolutizeServicePaths: bind mount paths made absolute", () => { + const svc: ServiceDef = { volumes: ["./data:/app/data:ro"] }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + assertEquals(Array.isArray(result.volumes), true); + assertEquals(result.volumes![0], "/project/app/data:/app/data:ro"); +}); + +Deno.test("absolutizeServicePaths: named volumes left unchanged", () => { + const svc: ServiceDef = { volumes: ["app-data:/var/lib/data"] }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + assertEquals(result.volumes![0], "app-data:/var/lib/data"); +}); + +Deno.test("absolutizeServicePaths: long-form bind mount made absolute", () => { + const svc: ServiceDef = { + volumes: [{ type: "bind", source: "./data", target: "/app/data" }], + }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + const vm = result.volumes![0] as Record; + assertEquals(vm.source, "/project/app/data"); +}); + +Deno.test("absolutizeServicePaths: long-form named volume left unchanged", () => { + const svc: ServiceDef = { + volumes: [{ type: "volume", source: "app-data", target: "/var/lib/data" }], + }; + const result = absolutizeServicePaths(svc, "/project/app", "/repo"); + const vm = result.volumes![0] as Record; + assertEquals(vm.source, "app-data"); +}); + +// --------------------------------------------------------------------------- +// coerceEnvironmentToDict +// --------------------------------------------------------------------------- + +Deno.test("coerceEnvironmentToDict: dict form", () => { + const result = coerceEnvironmentToDict({ FOO: "bar", BAZ: 42 }); + assertEquals(result, { FOO: "bar", BAZ: "42" }); +}); + +Deno.test("coerceEnvironmentToDict: list form KEY=VALUE", () => { + const result = coerceEnvironmentToDict(["FOO=bar", "BAZ=qux"]); + assertEquals(result, { FOO: "bar", BAZ: "qux" }); +}); + +Deno.test("coerceEnvironmentToDict: bare keys (no =) are skipped", () => { + const result = coerceEnvironmentToDict(["NO_EQUALS", "FOO=bar"]); + assertEquals(result, { FOO: "bar" }); +}); + +Deno.test("coerceEnvironmentToDict: null/undefined/not-present", () => { + assertEquals(coerceEnvironmentToDict(null), {}); + assertEquals(coerceEnvironmentToDict(undefined), {}); +}); + +Deno.test("coerceEnvironmentToDict: list with non-string items", () => { + const result = coerceEnvironmentToDict(["FOO=bar", 42 as unknown as string]); + assertEquals(result, { FOO: "bar" }); +}); + +// --------------------------------------------------------------------------- +// substitute +// --------------------------------------------------------------------------- + +Deno.test("substitute: ${VAR} substitution", () => { + const result = substitute("Hello ${NAME}", { NAME: "World" }); + assertEquals(result, "Hello World"); +}); + +Deno.test("substitute: ${VAR-default} — empty VAR counts as defined", () => { + const result = substitute("${VAR-fallback}", { VAR: "" }); + assertEquals(result, ""); // empty string is defined, so use it +}); + +Deno.test("substitute: ${VAR-default} — undefined VAR uses default", () => { + const result = substitute("${VAR-fallback}", {}); + assertEquals(result, "fallback"); +}); + +Deno.test("substitute: ${VAR:-default} — empty var uses default", () => { + const result = substitute("${VAR:-fallback}", { VAR: "" }); + assertEquals(result, "fallback"); +}); + +Deno.test("substitute: ${VAR:-default} — non-empty var uses value", () => { + const result = substitute("${VAR:-fallback}", { VAR: "ok" }); + assertEquals(result, "ok"); +}); + +Deno.test("substitute: ${VAR:-default} — undefined var uses default", () => { + const result = substitute("${VAR:-fallback}", {}); + assertEquals(result, "fallback"); +}); + +Deno.test("substitute: missing VAR left as-is", () => { + const result = substitute("Hello ${UNKNOWN}", {}); + assertEquals(result, "Hello ${UNKNOWN}"); +}); + +Deno.test("substitute: $VAR plain form", () => { + const result = substitute("Hello $NAME", { NAME: "World" }); + assertEquals(result, "Hello World"); +}); + +Deno.test("substitute: $$ preserved", () => { + const result = substitute("price: $$100", {}); + assertEquals(result, "price: $$100"); +}); + +Deno.test("substitute: mixed patterns", () => { + const vars = { APP: "myapp", PORT: "3000", MODE: "" }; + const input = "app=${APP} port=$PORT mode=${MODE:-production} missing=${MISSING-default}"; + const result = substitute(input, vars); + assertEquals(result, "app=myapp port=3000 mode=production missing=default"); +}); + +Deno.test("substitute: default with spaces in value", () => { + const result = substitute( + "${VAR:-default value with spaces}", + {}, + ); + assertEquals(result, "default value with spaces"); +}); + +// --------------------------------------------------------------------------- +// deepInterpolate +// --------------------------------------------------------------------------- + +Deno.test("deepInterpolate: string is interpolated", () => { + const result = deepInterpolate("${FOO}", { FOO: "bar" }); + assertEquals(result, "bar"); +}); + +Deno.test("deepInterpolate: array elements interpolated", () => { + const result = deepInterpolate(["${A}", "${B}"], { A: "1", B: "2" }); + assertEquals(result, ["1", "2"]); +}); + +Deno.test("deepInterpolate: dict values interpolated", () => { + const result = deepInterpolate({ key: "${VAL}", nested: { inner: "${X}" } }, { + VAL: "hello", + X: "world", + }); + assertEquals(result, { key: "hello", nested: { inner: "world" } }); +}); + +Deno.test("deepInterpolate: nested structures", () => { + const input = { + command: ["${APP}", "--port=${PORT}"], + environment: { APP_NAME: "${APP}" }, + volumes: ["${DATA_DIR}:/data"], + }; + const vars = { APP: "web", PORT: "8080", DATA_DIR: "/mnt" }; + const result = deepInterpolate(input, vars) as Record; + assertEquals((result.command as string[])[0], "web"); + assertEquals((result.command as string[])[1], "--port=8080"); + assertEquals((result.environment as Record).APP_NAME, "web"); + assertEquals((result.volumes as string[])[0], "/mnt:/data"); +}); + +Deno.test("deepInterpolate: non-string values unchanged", () => { + const input = { count: 42, enabled: true, name: null }; + const result = deepInterpolate(input, {}); + assertEquals(result, input); +}); + +Deno.test("deepInterpolate: numbers in arrays unchanged", () => { + const result = deepInterpolate([1, 2, "three"], {}); + assertEquals(result, [1, 2, "three"]); +}); + +// --------------------------------------------------------------------------- +// buildServiceScope +// --------------------------------------------------------------------------- + +Deno.test("buildServiceScope: layers shell -> env_file -> environment", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env", "DB_HOST=from_file\nDB_PORT=5432\n"); + + const svc: ServiceDef = { + env_file: `${dir}/.env`, + environment: { DB_HOST: "from_env" }, + }; + + const shellEnv = { SHELL_ONLY: "yes", DB_HOST: "from_shell" }; + + const scope = await buildServiceScope(svc, shellEnv, dir, dir); + // Shell env provides base + assertEquals(scope.SHELL_ONLY, "yes"); + // env_file layers on top + assertEquals(scope.DB_PORT, "5432"); + // service.environment wins over both + assertEquals(scope.DB_HOST, "from_env"); +}); + +Deno.test("buildServiceScope: multiple env_files layered in order", async () => { + const dir = await makeTempDir(); + await writeFile(dir, ".env.base", "A=base\nB=base"); + await writeFile(dir, ".env.override", "B=override\nC=override"); + + const svc: ServiceDef = { + env_file: [`${dir}/.env.base`, `${dir}/.env.override`], + }; + + const scope = await buildServiceScope(svc, {}, dir, dir); + assertEquals(scope.A, "base"); + assertEquals(scope.B, "override"); + assertEquals(scope.C, "override"); +}); + +Deno.test("buildServiceScope: missing env_file is silently skipped", async () => { + const dir = await makeTempDir(); + const svc: ServiceDef = { + env_file: `${dir}/nonexistent.env`, + environment: { FOO: "bar" }, + }; + + const scope = await buildServiceScope(svc, {}, dir, dir); + assertEquals(scope.FOO, "bar"); +}); + +// --------------------------------------------------------------------------- +// renderStack +// --------------------------------------------------------------------------- + +Deno.test("renderStack: full render with env files", async () => { + const dir = await makeTempDir(); + await writeFile(dir, "app.env", "APP_NAME=myapp\nAPP_PORT=8080\n"); + + const data = { + services: { + web: { + image: "nginx", + env_file: [`${dir}/app.env`], + environment: { CUSTOM: "value" }, + volumes: ["./data:/app/data"], + command: ["start", "--name=${APP_NAME}", "--port=${APP_PORT}"], + labels: { app: "${CUSTOM} ${APP_NAME}" }, + }, + }, + }; + + const result = await renderStack({ data, projectDir: dir, repoRoot: dir }); + const svc = result.data.services!["web"]; + + // Variables should be interpolated + assertEquals(svc.command, ["start", "--name=myapp", "--port=8080"]); + assertEquals(svc.labels, { app: "value myapp" }); + + // Volume path should be absolutized + const vol = svc.volumes![0] as string; + assertEquals(vol.startsWith("/"), true); +}); + +Deno.test("renderStack: strict mode detects unresolved", async () => { + const data = { + services: { + web: { + image: "nginx", + environment: { URL: "http://${UNRESOLVED}:8080" }, + }, + }, + }; + + const result = await renderStack({ + data, + projectDir: "/tmp", + repoRoot: "/tmp", + strict: true, + }); + + assertEquals(result.hasUnresolved, true); + assertEquals(result.warnings.some((w) => w.includes("Unresolved variable")), true); +}); + +Deno.test("renderStack: non-strict leaves unresolved as-is", async () => { + const data = { + services: { + web: { + image: "nginx", + command: "${MISSING_VAR}", + }, + }, + }; + + const result = await renderStack({ + data, + projectDir: "/tmp", + repoRoot: "/tmp", + strict: false, + }); + + assertEquals(result.hasUnresolved, undefined); + const cmd = result.data.services!.web.command as string; + assertEquals(cmd, "${MISSING_VAR}"); +}); + +Deno.test("renderStack: warnings for missing env files", async () => { + const dir = await makeTempDir(); + const data = { + services: { + web: { + image: "nginx", + env_file: "./nonexistent.env", + }, + }, + }; + + const result = await renderStack({ data, projectDir: dir, repoRoot: dir }); + assertEquals(result.warnings.some((w) => w.includes("env_file")), true); +}); + +Deno.test("renderStack: path absolutization", async () => { + const dir = await makeTempDir(); + const data = { + services: { + web: { + image: "nginx", + volumes: ["./data:/app/data", "named-vol:/data"], + }, + }, + }; + + const result = await renderStack({ data, projectDir: dir, repoRoot: dir }); + const vols = result.data.services!["web"].volumes as string[]; + // Named volume should be unchanged + assertEquals(vols[1], "named-vol:/data"); + // Bind mount should be absolute + assertEquals(vols[0].startsWith("/"), true); + assertStringIncludes(vols[0], ":/app/data"); +}); + +Deno.test("renderStack: service.environment in list form", async () => { + const data = { + services: { + web: { + image: "nginx", + environment: ["FOO=${BAR}", "BAZ=qux"], + }, + }, + }; + + // BAR is not set in env, so ${BAR} remains + const result = await renderStack({ data, projectDir: "/tmp", repoRoot: "/tmp" }); + const env = result.data.services!["web"].environment as string[]; + assertStringIncludes(env[0], "FOO="); +}); + +Deno.test("renderStack: does not mutate input", async () => { + const data = { + services: { + web: { + image: "nginx", + environment: { URL: "${SCHEME}://example.com" }, + }, + }, + }; + + const originalEnv = (data.services.web.environment as Record).URL; + await renderStack({ data, projectDir: "/tmp", repoRoot: "/tmp" }); + + // Input should still have the variable reference + assertEquals((data.services.web.environment as Record).URL, originalEnv); +});