Skip to content

Commit 5866678

Browse files
authored
feat(overrides): add profile and explicit override merge support
Adds Docker Compose override merge (scalars replace, maps merge, sequences append) with profile and --override support. Overrides stay on render/up/reload/plan commands only (not generate/sync per review feedback).
1 parent 99c6a35 commit 5866678

12 files changed

Lines changed: 1920 additions & 573 deletions

File tree

src/cli/mod.ts

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { initConfig } from "../config/mod.ts";
44
import { resolveConfig } from "../config/mod.ts";
55
import { ExitCode } from "../config/types.ts";
66
import { generateStacks } from "../compose/mod.ts";
7-
import type { GenerateOptions } from "../compose/mod.ts";
8-
import { join } from "@std/path";
9-
import { exists } from "@std/fs";
7+
import type { ComposeData, GenerateOptions } from "../compose/mod.ts";
8+
import { join, resolve } from "@std/path";
9+
import { ensureDir, exists } from "@std/fs";
10+
import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml";
11+
import { renderStack } from "../render/mod.ts";
1012

1113
/**
1214
* Parse and execute CLI commands.
@@ -65,6 +67,7 @@ export function buildCli(): Command {
6567
detect,
6668
preset,
6769
profile,
70+
writeGitignore,
6871
force,
6972
dryRun,
7073
cwd: Deno.cwd(),
@@ -100,6 +103,10 @@ export function buildCli(): Command {
100103
.option("--stacks <names:string>", "Comma-separated list of stack names to generate.")
101104
.option("--output-dir <path:string>", "Write generated stacks to a specific directory.")
102105
.option("--profile <name:string>", "Use a specific profile.")
106+
.option(
107+
"--override <files:string>",
108+
"Comma-separated list of override files to apply.",
109+
)
103110
.action(async (options: Record<string, unknown>) => {
104111
try {
105112
const profile = options.profile as string | undefined;
@@ -108,15 +115,19 @@ export function buildCli(): Command {
108115
const config = await resolveConfig({ profile, cwd: Deno.cwd() });
109116
const repoRoot = config.base.repoRoot ?? Deno.cwd();
110117

118+
// Parse override file paths
119+
const overrideFiles = options.override
120+
? (options.override as string).split(",").map((s: string) => s.trim()).filter(Boolean)
121+
: undefined;
122+
111123
const genOptions: GenerateOptions = {
112124
stacks: options.stacks
113125
? (options.stacks as string).split(",").map((s: string) => s.trim())
114126
: undefined,
115-
configStackNames: config.base.stack.names,
116127
repoRoot,
117128
outputDir: options.outputDir as string | undefined,
118129
dryRun,
119-
network: config.base.stack.network,
130+
overrides: overrideFiles,
120131
};
121132

122133
const result = await generateStacks(genOptions);
@@ -163,9 +174,91 @@ export function buildCli(): Command {
163174
"--override <files:string>",
164175
"Comma-separated list of override files to apply before rendering.",
165176
)
166-
.action(() => {
167-
console.error("render: not yet implemented (issue #5)");
168-
Deno.exit(1);
177+
.option("--dry-run", "Print rendered output without writing files.")
178+
.action(async (options: Record<string, unknown>) => {
179+
try {
180+
const profile = options.profile as string | undefined;
181+
const strict = options.strict as boolean | undefined;
182+
const dryRun = options.dryRun as boolean | undefined;
183+
const outputDir = options.outputDir as string | undefined;
184+
185+
const config = await resolveConfig({ profile, cwd: Deno.cwd() });
186+
const repoRoot = config.base.repoRoot ?? Deno.cwd();
187+
const renderOutputDir = outputDir || config.base.render.outputDirectory;
188+
189+
// 1. Generate stacks (in memory)
190+
const genResult = await generateStacks({
191+
stacks: options.stacks
192+
? (options.stacks as string).split(",").map((s: string) => s.trim())
193+
: undefined,
194+
repoRoot,
195+
outputDir: undefined, // generate in memory only
196+
dryRun: true, // generate in memory for render
197+
overrides: options.override
198+
? (options.override as string).split(",").map((s: string) => s.trim())
199+
: undefined,
200+
});
201+
202+
if (genResult.errors.length > 0) {
203+
for (const e of genResult.errors) console.error(`error: ${e}`);
204+
Deno.exit(ExitCode.DriftOrValidation);
205+
}
206+
207+
// 2. Render each generated stack
208+
const allWarnings: string[] = [];
209+
const results: Record<string, string> = {};
210+
let hasUnresolved = false;
211+
212+
for (const [stackName, yamlContent] of Object.entries(genResult.generated)) {
213+
const parsed = parseYaml(yamlContent) as ComposeData;
214+
const projectDir = repoRoot; // generated stacks live at repo root
215+
216+
const result = await renderStack({
217+
data: parsed,
218+
projectDir,
219+
repoRoot,
220+
strict,
221+
});
222+
223+
allWarnings.push(...result.warnings);
224+
if (result.hasUnresolved) hasUnresolved = true;
225+
226+
results[stackName] = `# Rendered by stackctl render — do not edit manually.\n${
227+
stringifyYaml(result.data, {
228+
indent: 2,
229+
lineWidth: 120,
230+
} as Record<string, unknown>)
231+
}`;
232+
}
233+
234+
// 3. Print warnings
235+
for (const w of allWarnings) {
236+
console.error(`warning: ${w}`);
237+
}
238+
239+
// 4. Output
240+
if (dryRun) {
241+
for (const [name, content] of Object.entries(results)) {
242+
console.log(`# --- rendered: ${name} ---`);
243+
console.log(content);
244+
}
245+
} else {
246+
const outDir = resolve(repoRoot, renderOutputDir);
247+
await ensureDir(outDir);
248+
for (const [name, content] of Object.entries(results)) {
249+
const outPath = join(outDir, `${name}.rendered.yml`);
250+
await Deno.writeTextFile(outPath, content);
251+
console.log(`wrote: ${outPath}`);
252+
}
253+
}
254+
255+
if (strict && hasUnresolved) {
256+
Deno.exit(ExitCode.DriftOrValidation);
257+
}
258+
} catch (err: unknown) {
259+
console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
260+
Deno.exit(ExitCode.UnexpectedError);
261+
}
169262
});
170263

171264
// --- up (issue #6) ---

0 commit comments

Comments
 (0)