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
65 changes: 60 additions & 5 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions src/compose/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { walk } from "@std/fs/walk";
import { parse as parseYaml } from "@std/yaml";
import { normalizeStackName } from "./load.ts";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -80,10 +81,20 @@ export async function discoverComposeFiles(
const parsed = parseYaml(raw) as Record<string, unknown> | 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) {
Expand Down
61 changes: 61 additions & 0 deletions src/compose/discover_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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`);
Expand Down
65 changes: 60 additions & 5 deletions src/compose/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,62 @@ import { resolve } from "@std/path";
export interface LoadResult {
/** Parsed compose data with `x-stack` key removed. */
data: Record<string, unknown>;
/** 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<string, unknown>;
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
// ---------------------------------------------------------------------------

/**
* 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<LoadResult> {
const raw = await Deno.readTextFile(path);
Expand All @@ -38,14 +82,25 @@ export async function loadCompose(path: string): Promise<LoadResult> {
);
}

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 };
}

/**
Expand Down
Loading