Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions src/compose/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,27 @@ async function generateSingleStack(
}),
);

// 2. Merge: compose data + fragment per-source, then merge all into one
// 2. Merge: combine data + fragment per-source, normalize paths against the
// source's own composeDir, then merge all sources together.
//
// Path provenance policy: `env_file` and relative bind-mount paths are
// resolved against the directory of the compose file that declared them
// (the same rule Docker Compose applies), BEFORE any cross-source merge.
// This prevents one source's paths leaking into another source's
// services. Absolute paths and named volumes are preserved as-is.
// Generated output is always repo-root-relative for surviving paths.
let merged: ComposeData = {};
for (const src of sources) {
const combined = composeDeepMerge(src.data, src.fragment);
let combined = composeDeepMerge(src.data, src.fragment);
if (combined.services) {
const normalized: Record<string, ServiceDef> = {};
for (const [svcName, svc] of Object.entries(combined.services)) {
let t = rewriteEnvFile(svc, src.composeDir, repoRoot);
t = rewriteBindMountPaths(t, src.composeDir, repoRoot);
normalized[svcName] = t;
}
combined = { ...combined, services: normalized };
}
merged = composeDeepMerge(merged, combined);
}

Expand All @@ -160,14 +177,18 @@ async function generateSingleStack(
merged = await applyOverrides(merged, overrides, repoRoot);
}

// 3. Transform services
// 3. Transform services.
// Source paths were already normalized in step 2; re-running the path
// rewrites against repoRoot is idempotent for them and normalizes any
// relative paths introduced by overrides deterministically relative to
// repoRoot (absolute paths and named volumes remain untouched).
if (merged.services) {
const transformed: Record<string, ServiceDef> = {};
for (const [svcName, svc] of Object.entries(merged.services)) {
let t = stripComposeOnlyKeys(svc);
t = applyLoggingDefaults(t);
t = rewriteEnvFile(t, sources[0]?.composeDir ?? "", repoRoot);
t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot);
t = rewriteEnvFile(t, repoRoot, repoRoot);
t = rewriteBindMountPaths(t, repoRoot, repoRoot);
transformed[svcName] = t;
}
merged = { ...merged, services: transformed };
Expand Down
275 changes: 275 additions & 0 deletions src/compose/generate_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* Tests for the full stack generation pipeline.
*/
import { assertEquals, assertStringIncludes } from "@std/assert";
import { parse as parseYaml } from "@std/yaml";
import { generateStacks } from "./generate.ts";
import type { GenerateOptions } from "./generate.ts";
import type { ComposeData, ServiceDef } from "./types.ts";
import { renderStack } from "../render/mod.ts";

async function makeTempDir(): Promise<string> {
return await Deno.makeTempDir({ prefix: "stackctl-test-generate-" });
Expand Down Expand Up @@ -254,3 +257,275 @@ Deno.test("generateStacks: services stripped of compose-only keys", async () =>

await Deno.remove(tmp, { recursive: true });
});

// ---------------------------------------------------------------------------
// Path provenance (env_file / bind mounts) across multiple sources
// ---------------------------------------------------------------------------

/**
* Create a stack ("platform") with two source directories, each declaring
* distinct services with their own env files and volume mounts.
*
* - services/web: short-form relative bind mount, named volume, absolute
* mount, and a fragment that adds a `worker` service.
* - services/api: long-form bind mount, long-form named volume, absolute
* short-form mount.
*/
async function createTwoServiceFixture(repoRoot: string) {
const webDir = `${repoRoot}/services/web`;
await Deno.mkdir(webDir, { recursive: true });

await writeFile(
webDir,
"docker-compose.yml",
[
"x-stack: platform",
"",
"services:",
" web:",
" image: nginx:alpine",
" env_file:",
' - ".env"',
" labels:",
' app: "${SERVICE}-frontend"',
" volumes:",
' - "./html:/usr/share/nginx/html"',
' - "app-data:/var/lib/data"',
' - "/etc/ssl:/ssl:ro"',
"",
"volumes:",
" app-data:",
"",
].join("\n"),
);
await writeFile(webDir, ".env", "SERVICE=web\n");
await writeFile(
webDir,
"swarm.fragment.yml",
[
"services:",
" worker:",
" image: busybox",
" volumes:",
' - "./frag:/frag"',
"",
].join("\n"),
);

const apiDir = `${repoRoot}/services/api`;
await Deno.mkdir(apiDir, { recursive: true });

await writeFile(
apiDir,
"docker-compose.yml",
[
"x-stack: platform",
"",
"services:",
" api:",
" image: node:20",
" env_file:",
' - ".env"',
" labels:",
' app: "${SERVICE}-backend"',
" volumes:",
" - type: bind",
' source: "./static"',
' target: "/srv/static"',
" - type: volume",
' source: "api-data"',
' target: "/data"',
' - "/etc/hosts:/etc/hosts"',
"",
"volumes:",
" api-data:",
"",
].join("\n"),
);
await writeFile(apiDir, ".env", "SERVICE=api\n");
}

function parseGenerated(content: string): ComposeData {
return parseYaml(content) as ComposeData;
}

Deno.test("generateStacks: env_file paths keep their own source composeDir", async () => {
const tmp = await makeTempDir();
await createTwoServiceFixture(tmp);

const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true });
assertEquals(result.errors, []);

const parsed = parseGenerated(result.generated["platform"]);
const web = parsed.services!["web"];
const api = parsed.services!["api"];

// Each service's env_file resolves against ITS OWN dir, never sources[0]
assertEquals(web.env_file, ["./services/web/.env"]);
assertEquals(api.env_file, ["./services/api/.env"]);

// Worker comes from web's fragment — also normalized against web's dir
assertEquals(parsed.services!["worker"].volumes, ["./services/web/frag:/frag"]);

await Deno.remove(tmp, { recursive: true });
});

Deno.test("generateStacks: bind mounts keep their own source composeDir", async () => {
const tmp = await makeTempDir();
await createTwoServiceFixture(tmp);

const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true });
assertEquals(result.errors, []);

const parsed = parseGenerated(result.generated["platform"]);
const web = parsed.services!["web"];
const api = parsed.services!["api"];

// Short-form relative bind mount → own dir
assertEquals(web.volumes?.[0], "./services/web/html:/usr/share/nginx/html");
// Named volume preserved
assertEquals(web.volumes?.[1], "app-data:/var/lib/data");
// Absolute mount preserved
assertEquals(web.volumes?.[2], "/etc/ssl:/ssl:ro");

// Long-form bind mount → own dir
assertEquals(api.volumes?.[0], {
type: "bind",
source: "./services/api/static",
target: "/srv/static",
});
// Long-form named volume preserved
assertEquals(api.volumes?.[1], { type: "volume", source: "api-data", target: "/data" });
// Absolute short-form mount preserved
assertEquals(api.volumes?.[2], "/etc/hosts:/etc/hosts");

await Deno.remove(tmp, { recursive: true });
});

Deno.test("generateStacks: same service across sources normalized per-source before merge", async () => {
const tmp = await makeTempDir();
const webDir = `${tmp}/services/web`;
const apiDir = `${tmp}/services/api`;
await Deno.mkdir(webDir, { recursive: true });
await Deno.mkdir(apiDir, { recursive: true });

// Both sources declare a "web" service. Each field must be normalized
// against the directory that declared it BEFORE the cross-source merge.
await writeFile(
webDir,
"docker-compose.yml",
[
"x-stack: platform",
"services:",
" web:",
" image: nginx:alpine",
" env_file:",
' - ".env"',
"",
].join("\n"),
);
await writeFile(
apiDir,
"docker-compose.yml",
[
"x-stack: platform",
"services:",
" web:",
" image: nginx:alpine",
" volumes:",
' - "./html:/usr/share/nginx/html"',
"",
].join("\n"),
);

const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true });
assertEquals(result.errors, []);

const parsed = parseGenerated(result.generated["platform"]);
const web = parsed.services!["web"] as ServiceDef;

// env_file declared in web's dir → normalized against web's dir
assertEquals(web.env_file, ["./services/web/.env"]);
// bind mount declared in api's dir → normalized against api's dir, NOT sources[0]
assertEquals(web.volumes, ["./services/api/html:/usr/share/nginx/html"]);

await Deno.remove(tmp, { recursive: true });
});

Deno.test("generateStacks: override-introduced relative paths normalize against repoRoot", async () => {
const tmp = await makeTempDir();
await createTwoServiceFixture(tmp);

const overridesDir = `${tmp}/overrides`;
await Deno.mkdir(overridesDir, { recursive: true });
await writeFile(
overridesDir,
"prod.yml",
[
"services:",
" web:",
" env_file:",
' - "prod.env"',
" volumes:",
' - "./overrides/data:/data"',
' - "/opt/override:/opt"',
' - "extra-vol:/extra"',
"",
].join("\n"),
);

const result = await generateStacks({
stacks: ["platform"],
repoRoot: tmp,
dryRun: true,
overrides: ["overrides/prod.yml"],
});
assertEquals(result.errors, []);

const parsed = parseGenerated(result.generated["platform"]);
const web = parsed.services!["web"] as ServiceDef;

// env_file arrays APPEND per Docker override semantics: the source entry
// keeps its own provenance, the override entry is repo-root-relative.
assertEquals(web.env_file, ["./services/web/.env", "./prod.env"]);
// volumes APPEND: source mounts unchanged, override relative bind mount is
// repo-root-relative, absolute paths and named volumes preserved.
assertEquals(web.volumes, [
"./services/web/html:/usr/share/nginx/html",
"app-data:/var/lib/data",
"/etc/ssl:/ssl:ro",
"./overrides/data:/data",
"/opt/override:/opt",
"extra-vol:/extra",
]);

await Deno.remove(tmp, { recursive: true });
});

Deno.test("generateStacks + render: each service reads its own env file", async () => {
const tmp = await makeTempDir();
await createTwoServiceFixture(tmp);

const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true });
assertEquals(result.errors, []);

// Render the generated stack exactly like the CLI does (projectDir = repoRoot)
const rendered = await renderStack({
data: parseGenerated(result.generated["platform"]),
projectDir: tmp,
repoRoot: tmp,
});

// The ${SERVICE} placeholder in each service's labels is substituted from
// the rendered data — if a service read the wrong env file, its label
// would contain the other service's value.
const web = rendered.data.services!["web"];
const api = rendered.data.services!["api"];
assertEquals(web.labels, { app: "web-frontend" });
assertEquals(api.labels, { app: "api-backend" });
// env_file is absolutized to each service's own file by rendering
assertEquals(web.env_file, [`${tmp}/services/web/.env`]);
assertEquals(api.env_file, [`${tmp}/services/api/.env`]);

await Deno.remove(tmp, { recursive: true });
});