diff --git a/src/compose/generate.ts b/src/compose/generate.ts index 9efdaeb..65432d3 100644 --- a/src/compose/generate.ts +++ b/src/compose/generate.ts @@ -19,45 +19,24 @@ import { import { collectAllNamedVolumes } from "./volumes.ts"; import type { ComposeData, ServiceDef } from "./types.ts"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - export interface GenerateOptions { - /** Stack names to generate (undefined = all discovered). */ stacks?: string[]; - /** Repository root path. */ + configStackNames?: string[]; repoRoot: string; - /** Output directory for generated stacks (default: /stacks). */ outputDir?: string; - /** Whether this is a dry run (no files written). */ dryRun?: boolean; + network?: 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[]; } -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const NETWORK_NAME = "traefik-public"; - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- +const DEFAULT_NETWORK_NAME = "traefik-public"; -/** - * Generate canonical Swarm stack files from per-service Compose sources. - */ export async function generateStacks( options: GenerateOptions, ): Promise { @@ -69,32 +48,33 @@ 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); } - // 2. Determine which stacks to generate - const targetStacks = options.stacks ?? Object.keys(discovery.stacks); + const targetStacks = options.stacks ?? + (options.configStackNames && options.configStackNames.length > 0 + ? options.configStackNames + : 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); } - // 4. Generate each stack + const network = options.network || DEFAULT_NETWORK_NAME; + 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; } @@ -102,11 +82,12 @@ export async function generateStacks( stackName, composePaths, options.repoRoot, + network, ); result.generated[stackName] = output; - const outPath = join(outputDir, `${stackName}.yml`); + const outPath = join(outputDir, stackName + ".yml"); if (options.dryRun) { result.files.push(outPath); } else { @@ -115,7 +96,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)), ); } } @@ -123,16 +104,14 @@ export async function generateStacks( return result; } -// --------------------------------------------------------------------------- -// Single-stack generation -// --------------------------------------------------------------------------- - async function generateSingleStack( _stackName: string, composePaths: string[], repoRoot: string, + network?: string, ): Promise { - // 1. Load all compose files + fragments + const networkName = network || DEFAULT_NETWORK_NAME; + const sources = await Promise.all( composePaths.map(async (path) => { const composeDir = path.substring(0, path.lastIndexOf("/")); @@ -142,60 +121,86 @@ async function generateSingleStack( }), ); - // 2. Merge: compose data + fragment per-source, then merge all into one + 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); + } + } + } + } + let merged: ComposeData = {}; for (const src of sources) { const combined = composeDeepMerge(src.data, src.fragment); merged = composeDeepMerge(merged, combined); } - // 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, sources[0]?.composeDir ?? "", repoRoot); - t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot); + t = rewriteEnvFile(t, svcDir, repoRoot); + t = rewriteBindMountPaths(t, svcDir, 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) { - output.services = merged.services; + const svcs: Record = {}; + for (const key of Object.keys(merged.services).sort()) { + svcs[key] = merged.services[key]; + } + output.services = svcs; } - // Networks output.networks = { default: { - name: NETWORK_NAME, + name: networkName, external: true, }, }; - // Volumes (only if named volumes exist) if (namedVolumes.length > 0) { const volumes: Record = {}; - for (const name of namedVolumes) { - volumes[name] = { external: true }; + 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 }; + } } 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, - noRefs: true, - } as Record); + useAnchors: false, + sortKeys: true, + }); return header + body; } diff --git a/src/compose/generate_test.ts b/src/compose/generate_test.ts index 0a55e9c..708f3ee 100644 --- a/src/compose/generate_test.ts +++ b/src/compose/generate_test.ts @@ -10,12 +10,11 @@ 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) { - // Create a service directory with compose + fragment - const svcDir = `${repoRoot}/services/web`; + const svcDir = repoRoot + "/services/web"; await Deno.mkdir(svcDir, { recursive: true }); await writeFile( @@ -54,31 +53,21 @@ async function createFixture(repoRoot: string) { } // --------------------------------------------------------------------------- -// Tests +// Original tests // --------------------------------------------------------------------------- Deno.test("generateStacks: single stack dry-run returns content", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const options: GenerateOptions = { - stacks: ["platform"], - repoRoot: tmp, - dryRun: true, - }; - - const result = await generateStacks(options); + const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); 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 }); @@ -88,21 +77,17 @@ Deno.test("generateStacks: writes files when not dry-run", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const options: GenerateOptions = { + const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, - outputDir: `${tmp}/stacks`, + outputDir: tmp + "/stacks", dryRun: false, - }; - - const result = await generateStacks(options); + }); assertEquals(result.errors, []); - assertEquals(result.files.length, 1); - assertEquals(result.files[0], `${tmp}/stacks/platform.yml`); + assertEquals(result.files[0], tmp + "/stacks/platform.yml"); - // Verify file was actually written - const written = await Deno.readTextFile(`${tmp}/stacks/platform.yml`); + const written = await Deno.readTextFile(tmp + "/stacks/platform.yml"); assertStringIncludes(written, "# Generated by stackctl generate"); await Deno.remove(tmp, { recursive: true }); @@ -110,49 +95,28 @@ Deno.test("generateStacks: writes files when not dry-run", async () => { Deno.test("generateStacks: generates all stacks when no filter", async () => { const tmp = await makeTempDir(); - - // Create two stacks - const svcA = `${tmp}/services/api`; - const svcB = `${tmp}/services/db`; + 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 options: GenerateOptions = { - repoRoot: tmp, - dryRun: true, - }; - - const result = await generateStacks(options); + const result = await generateStacks({ repoRoot: tmp, dryRun: true }); assertEquals(result.errors, []); assertEquals(Object.keys(result.generated).length, 2); - - const content1 = result.generated["infra"]; - const content2 = result.generated["platform"]; - assertStringIncludes(content1, "api:"); - assertStringIncludes(content2, "db:"); + assertStringIncludes(result.generated["infra"], "api:"); + assertStringIncludes(result.generated["platform"], "db:"); await Deno.remove(tmp, { recursive: true }); }); @@ -161,13 +125,7 @@ Deno.test("generateStacks: reports error for nonexistent stack", async () => { const tmp = await makeTempDir(); await createFixture(tmp); - const options: GenerateOptions = { - stacks: ["nonexistent"], - repoRoot: tmp, - dryRun: true, - }; - - const result = await generateStacks(options); + const result = await generateStacks({ stacks: ["nonexistent"], repoRoot: tmp, dryRun: true }); assertEquals(result.errors.length, 1); assertStringIncludes(result.errors[0], "nonexistent"); @@ -178,12 +136,7 @@ Deno.test("generateStacks: reports error for nonexistent stack", async () => { Deno.test("generateStacks: warnings for empty repo", async () => { const tmp = await makeTempDir(); - const options: GenerateOptions = { - repoRoot: tmp, - dryRun: true, - }; - - const result = await generateStacks(options); + const result = await generateStacks({ repoRoot: tmp, dryRun: true }); assertEquals(result.warnings.length, 1); assertStringIncludes(result.warnings[0], "No stacks discovered"); @@ -222,7 +175,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( @@ -244,13 +197,204 @@ 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 }); +});