From 5fbbac433058fbb97758408d0880731bf6c5c6a1 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Mon, 20 Jul 2026 22:35:46 +0200 Subject: [PATCH] feat(compose): support object x-stack metadata --- docs/migration.md | 65 ++++++++++++-- src/compose/discover.ts | 17 +++- src/compose/discover_test.ts | 61 +++++++++++++ src/compose/load.ts | 65 ++++++++++++-- src/compose/load_test.ts | 167 ++++++++++++++++++++++++++++++++++- src/config/defaults.ts | 1 - src/config/init.ts | 4 +- src/config/init_test.ts | 1 + src/config/types.ts | 2 - 9 files changed, 363 insertions(+), 20 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 5ccf716..fd0fdb6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -78,7 +78,7 @@ Create a `.stackctl` file (generated via `stackctl init`): project: myproject stack: - # Service directories containing compose files with x-stack labels + # Root directory for service compose files (each must declare x-stack metadata) directory: ./stack # Stack names to manage (empty = all discovered) names: [] @@ -245,6 +245,57 @@ stackctl up --override ./overrides/production.yml --override ./overrides/region- Override files are applied _after_ profile merging but _before_ render. +## Compose Metadata (`x-stack`) + +`stackctl` uses the `x-stack` compose metadata key to group docker-compose files into +named stacks during discovery and generation. Every compose file must declare which +stack it belongs to. + +### Supported Forms + +Two forms are supported: + +**Scalar (legacy)** -- a plain stack name: + +```yaml +services: + api: + image: myapp/api +x-stack: api +``` + +**Object (v1)** -- a map with a `name` field: + +```yaml +services: + api: + image: myapp/api +x-stack: + name: api +``` + +Both forms are equivalent and normalize to the same stack name. Scalar and object +forms can coexist within the same stack group; they are merged by normalized name. + +### Object Form Constraints + +The object form currently accepts the `name` field **only**. Adding unknown fields +will cause an error: + +```yaml +# Invalid -- "labels" is not a recognized field +x-stack: + name: api + labels: [production] +``` + +During discovery, files with invalid `x-stack` metadata are skipped for grouping +and reported as errors. During explicit loading (e.g. `stackctl generate`), invalid +metadata causes the command to fail with a descriptive message. + +The `x-stack` key is **source-only metadata**. It is stripped from generated and +rendered stack output and never appears in deployed compose files. + ## Rollback ### Rollback a Deployment @@ -313,15 +364,19 @@ docker swarm init ✗ Stack "myapp" not found in /path/to/project ``` -Check that compose files have the `x-stack` label and are in the configured `stack.directory`: +Check that your compose files declare `x-stack` metadata and are located within the +configured `stack.directory`. See [Compose Metadata (`x-stack`)](#compose-metadata-x-stack) +for supported forms. ```yaml -# docker-compose.yml +# docker-compose.yml -- either form works: services: api: image: myapp/api -x-stack: - name: myapp +x-stack: myapp # scalar form +# or: +# x-stack: +# name: myapp # object form ``` ### Config Validation Errors diff --git a/src/compose/discover.ts b/src/compose/discover.ts index 806df7a..c8a8fed 100644 --- a/src/compose/discover.ts +++ b/src/compose/discover.ts @@ -4,6 +4,7 @@ */ import { walk } from "@std/fs/walk"; import { parse as parseYaml } from "@std/yaml"; +import { normalizeStackName } from "./load.ts"; // --------------------------------------------------------------------------- // Types @@ -80,10 +81,20 @@ export async function discoverComposeFiles( const parsed = parseYaml(raw) as Record | null; if (!parsed || typeof parsed !== "object") continue; - const stackName = parsed["x-stack"]; - if (typeof stackName !== "string" || stackName.trim() === "") continue; + const rawStack = parsed["x-stack"]; + if (rawStack === undefined || rawStack === null) continue; + + let nameStr: string; + try { + nameStr = normalizeStackName(rawStack); + } catch (err: unknown) { + errors.push({ + path: entry.path, + message: `Invalid x-stack metadata: ${err instanceof Error ? err.message : String(err)}`, + }); + continue; + } - const nameStr = stackName.trim(); if (!stacks[nameStr]) stacks[nameStr] = []; stacks[nameStr].push(entry.path); } catch (err: unknown) { diff --git a/src/compose/discover_test.ts b/src/compose/discover_test.ts index 80f5234..2b456be 100644 --- a/src/compose/discover_test.ts +++ b/src/compose/discover_test.ts @@ -39,6 +39,50 @@ Deno.test("discover: finds compose files with x-stack", async () => { await Deno.remove(tmp, { recursive: true }); }); +Deno.test("discover: finds compose files with object-form x-stack", async () => { + const tmp = await makeTempDir(); + + await writeYaml(tmp, "docker-compose.yml", { + "x-stack": { name: "infra" }, + services: { app: { image: "alpine" } }, + }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), ["infra"]); + assertEquals(result.stacks["infra"].length, 1); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: reports error for invalid object-form x-stack", async () => { + const tmp = await makeTempDir(); + + await writeYaml(tmp, "docker-compose.yml", { + "x-stack": { name: "infra", unknown: "bad" }, + services: { app: { image: "alpine" } }, + }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + // Invalid x-stack should be skipped (not grouped) + assertEquals(Object.keys(result.stacks), []); + // Error should be reported + assertEquals(result.errors.length, 1); + assertEquals(result.errors[0].path, `${tmp}/docker-compose.yml`); + assertEquals( + result.errors[0].message.includes("Invalid x-stack metadata"), + true, + ); + assertEquals( + result.errors[0].message.includes("Unknown field"), + true, + ); + + await Deno.remove(tmp, { recursive: true }); +}); + Deno.test("discover: finds docker-compose.yaml files", async () => { const tmp = await makeTempDir(); @@ -87,6 +131,23 @@ Deno.test("discover: groups files by stack name", async () => { await Deno.remove(tmp, { recursive: true }); }); +Deno.test("discover: groups mixed scalar and object x-stack forms", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/svc-a`); + await Deno.mkdir(`${tmp}/svc-b`); + + await writeYaml(`${tmp}/svc-a`, "docker-compose.yml", { "x-stack": "infra" }); + await writeYaml(`${tmp}/svc-b`, "docker-compose.yml", { "x-stack": { name: "infra" } }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), ["infra"]); + assertEquals(result.stacks["infra"].length, 2); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + Deno.test("discover: skips hidden directories", async () => { const tmp = await makeTempDir(); await Deno.mkdir(`${tmp}/.hidden`); diff --git a/src/compose/load.ts b/src/compose/load.ts index 52fdd35..b0c26c0 100644 --- a/src/compose/load.ts +++ b/src/compose/load.ts @@ -14,10 +14,54 @@ import { resolve } from "@std/path"; export interface LoadResult { /** Parsed compose data with `x-stack` key removed. */ data: Record; - /** Value of the `x-stack` key (the stack name). */ + /** Normalized stack name extracted from the `x-stack` key. */ stackName: string; } +// --------------------------------------------------------------------------- +// x-stack normalizer +// --------------------------------------------------------------------------- + +/** + * Normalize the `x-stack` compose metadata field to a stack name string. + * + * Supports two forms: + * - legacy scalar: `x-stack: infra` + * - object (v1): `x-stack: { name: infra }` + * + * Throws on invalid values, unknown object fields, or missing/empty names. + */ +export function normalizeStackName(value: unknown): string { + // Legacy scalar form + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed === "") { + throw new Error("x-stack value must be a non-empty string"); + } + return trimmed; + } + + // Object form: must be a plain record + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const obj = value as Record; + const knownKeys = new Set(["name"]); + + for (const key of Object.keys(obj)) { + if (!knownKeys.has(key)) { + throw new Error(`Unknown field in x-stack object: "${key}"`); + } + } + + const name = obj["name"]; + if (typeof name !== "string" || name.trim() === "") { + throw new Error('x-stack object must have a non-empty "name" field'); + } + return name.trim(); + } + + throw new Error("x-stack must be a string or {name: string} object"); +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -25,7 +69,7 @@ export interface LoadResult { /** * Load a docker-compose YAML file and extract its `x-stack` value. * - * Throws if the file cannot be parsed or if `x-stack` is missing / non-string. + * Throws if the file cannot be parsed or if `x-stack` is missing / invalid. */ export async function loadCompose(path: string): Promise { const raw = await Deno.readTextFile(path); @@ -38,14 +82,25 @@ export async function loadCompose(path: string): Promise { ); } - const stackName = parsed["x-stack"]; - if (typeof stackName !== "string" || stackName.trim() === "") { + const rawStack = parsed["x-stack"]; + if (rawStack === undefined || rawStack === null) { throw new Error(`Compose file ${path} is missing a valid "x-stack" key`); } + let stackName: string; + try { + stackName = normalizeStackName(rawStack); + } catch (err: unknown) { + throw new Error( + `Compose file ${path} has invalid "x-stack": ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + // Return a copy with x-stack removed const { "x-stack": _, ...data } = parsed; - return { data, stackName: stackName.trim() }; + return { data, stackName }; } /** diff --git a/src/compose/load_test.ts b/src/compose/load_test.ts index d526835..bbe078e 100644 --- a/src/compose/load_test.ts +++ b/src/compose/load_test.ts @@ -1,8 +1,8 @@ /** * Tests for compose file loading. */ -import { assertEquals, assertRejects } from "@std/assert"; -import { loadCompose, loadFragment } from "./load.ts"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { loadCompose, loadFragment, normalizeStackName } from "./load.ts"; async function makeTempDir(): Promise { return await Deno.makeTempDir({ prefix: "stackctl-test-load-" }); @@ -12,6 +12,77 @@ async function writeFile(dir: string, name: string, content: string) { await Deno.writeTextFile(`${dir}/${name}`, content); } +// --------------------------------------------------------------------------- +// normalizeStackName unit tests +// --------------------------------------------------------------------------- + +Deno.test("normalizeStackName: scalar form returns trimmed string", () => { + assertEquals(normalizeStackName("infra"), "infra"); + assertEquals(normalizeStackName(" infra "), "infra"); +}); + +Deno.test("normalizeStackName: object form with name field", () => { + assertEquals(normalizeStackName({ name: "infra" }), "infra"); + assertEquals(normalizeStackName({ name: " infra " }), "infra"); +}); + +Deno.test("normalizeStackName: throws on empty scalar", () => { + assertThrows( + () => normalizeStackName(""), + Error, + "non-empty", + ); +}); + +Deno.test("normalizeStackName: throws on whitespace-only scalar", () => { + assertThrows( + () => normalizeStackName(" "), + Error, + "non-empty", + ); +}); + +Deno.test("normalizeStackName: throws on object missing name field", () => { + assertThrows( + () => normalizeStackName({}), + Error, + '"name"', + ); +}); + +Deno.test("normalizeStackName: throws on object with empty name", () => { + assertThrows( + () => normalizeStackName({ name: "" }), + Error, + '"name"', + ); +}); + +Deno.test("normalizeStackName: throws on unknown object field", () => { + assertThrows( + () => normalizeStackName({ name: "infra", unknown: "bad" }), + Error, + "Unknown field", + ); +}); + +Deno.test("normalizeStackName: throws on non-string non-object values", () => { + assertThrows( + () => normalizeStackName(42), + Error, + "must be a string or", + ); + assertThrows( + () => normalizeStackName(["infra"]), + Error, + "must be a string or", + ); +}); + +// --------------------------------------------------------------------------- +// loadCompose integration tests +// --------------------------------------------------------------------------- + Deno.test("loadCompose: parses valid compose with x-stack", async () => { const tmp = await makeTempDir(); await writeFile( @@ -37,6 +108,29 @@ Deno.test("loadCompose: parses valid compose with x-stack", async () => { await Deno.remove(tmp, { recursive: true }); }); +Deno.test("loadCompose: parses object-form x-stack", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "x-stack:", + " name: infra", + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + const result = await loadCompose(`${tmp}/docker-compose.yml`); + + assertEquals(result.stackName, "infra"); + assertEquals(result.data.services, { app: { image: "alpine" } }); + assertEquals((result.data as Record)["x-stack"], undefined); + + await Deno.remove(tmp, { recursive: true }); +}); + Deno.test("loadCompose: throws on missing x-stack", async () => { const tmp = await makeTempDir(); await writeFile( @@ -80,6 +174,75 @@ Deno.test("loadCompose: throws on empty x-stack value", async () => { await Deno.remove(tmp, { recursive: true }); }); +Deno.test("loadCompose: throws on null x-stack", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "x-stack:", + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + await assertRejects( + () => loadCompose(`${tmp}/docker-compose.yml`), + Error, + "x-stack", + ); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadCompose: throws on object with unknown field", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "x-stack:", + " name: infra", + " foo: bar", + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + await assertRejects( + () => loadCompose(`${tmp}/docker-compose.yml`), + Error, + "Unknown field", + ); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadCompose: throws on object with empty name", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "x-stack:", + ' name: ""', + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + await assertRejects( + () => loadCompose(`${tmp}/docker-compose.yml`), + Error, + "x-stack", + ); + + await Deno.remove(tmp, { recursive: true }); +}); + Deno.test("loadFragment: returns {} when fragment is absent", async () => { const tmp = await makeTempDir(); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 47027c7..56fa188 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -12,7 +12,6 @@ export const DEFAULT_CONFIG: StackctlConfig = { directory: "stacks", names: [], network: "", - composeStackKey: "x-stack", skipDirectories: [], networkDriver: "overlay", }, diff --git a/src/config/init.ts b/src/config/init.ts index 5f671a6..bf4234e 100644 --- a/src/config/init.ts +++ b/src/config/init.ts @@ -44,6 +44,7 @@ export interface InitResult { const TEMPLATE = `# stackctl configuration # Generated by \`stackctl init\` # See https://github.com/AniTrend/stackctl for documentation +# Compose file annotation guide: docs/migration.md#compose-metadata-x-stack # Project name used for stack naming and identification. # This should match your repository or project name. @@ -57,8 +58,6 @@ stack: # These are the top-level stack names, not individual services. names: - "example" # Replace with your stack names - # Compose metadata key for stack grouping (default: "x-stack") - composeStackKey: "x-stack" # Directories to skip during service discovery skipDirectories: [] # External overlay network name for all stacks @@ -95,6 +94,7 @@ env: const MINIMAL_TEMPLATE = `# stackctl minimal configuration # Generated by \`stackctl init --preset minimal\` # See https://github.com/AniTrend/stackctl for documentation +# Compose file annotation guide: docs/migration.md#compose-metadata-x-stack project: "" diff --git a/src/config/init_test.ts b/src/config/init_test.ts index 4946bce..c584db9 100644 --- a/src/config/init_test.ts +++ b/src/config/init_test.ts @@ -22,6 +22,7 @@ Deno.test("initConfig: default template contains expected sections", async () => assertStringIncludes(content, "env:"); assertStringIncludes(content, "activeName:"); assertStringIncludes(content, "Generated by `stackctl init`"); + assertStringIncludes(content, "docs/migration.md#compose-metadata-x-stack"); } finally { await Deno.remove(tmpDir, { recursive: true }); } diff --git a/src/config/types.ts b/src/config/types.ts index 0f791e5..3c96237 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -30,8 +30,6 @@ export interface StackConfig { directory: string; /** Generated stack names. */ names: string[]; - /** Compose discovery metadata key (default: "x-stack"). */ - composeStackKey?: string; /** Directories to skip during discovery. */ skipDirectories?: string[]; /** External network name for all stacks. */