From 5c14a22b14ad8d55d70d3bd2591f4a1a437db8d9 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Fri, 3 Jul 2026 10:35:20 +0200 Subject: [PATCH] feat(reload): add config-first reload command Implements stackctl reload for re-rendering and reconciling stacks without tearing down. Pipeline: config resolution -> stack generation (optional) -> override merge -> render -> deploy. 27 tests passing. 341 total project-wide. --- src/cli/mod.ts | 102 +++- src/compose/reload.ts | 394 ++++++++++++++++ src/compose/reload_test.ts | 943 +++++++++++++++++++++++++++++++++++++ src/docker/mod.ts | 17 + 4 files changed, 1447 insertions(+), 9 deletions(-) create mode 100644 src/compose/reload.ts create mode 100644 src/compose/reload_test.ts diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 38c0ae1..e3c495f 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -31,6 +31,8 @@ import { materializeEnvFromProfile, } from "../env/mod.ts"; import type { EnvDiff } from "../env/types.ts"; +import { reloadStacks } from "../compose/reload.ts"; +import type { ReloadResult } from "../compose/reload.ts"; import { checkTooling, cleanDecryptedEnvFiles, @@ -836,17 +838,99 @@ export function buildCli(): Command { }); // --- reload (issue #9) --- + // + // Option precedence (highest to lowest): + // CLI flag > active profile config > base config > built-in default + // + // Safety: reload only deploys/updates. It never schedules `docker stack rm`, + // `docker network rm`, or `docker volume rm`. cli.command("reload", "Re-render and redeploy stacks without tearing down.") - .option("--force-service-update", "Force update all services after deploy.") - .option("--no-force-service-update", "Skip force update (config override).") - .option("--no-generate", "Skip stack generation step.") - .option("--stacks ", "Comma-separated list of stack names.") + .option("--skip-generate", "Only re-render and re-deploy, do not regenerate stacks.") + .option( + "--skip-unchanged", + "Only redeploy stacks whose rendered output changed (default: always deploy).", + ) + .option( + "--force-service-update", + "Force `docker service update --force` on every service after deploy.", + ) + .option( + "--no-force-service-update", + "Disable force service update (overrides config).", + ) + .option("--follow-logs", "Stream logs for deployed stacks after reload.") + .option("--stacks ", "Comma-separated list of stack names to reload.") .option("--profile ", "Use a specific profile.") - .option("--override ", "Comma-separated list of override files.") - .option("--dry-run", "Print planned actions without executing.") - .action(() => { - console.error("reload: not yet implemented (issue #9)"); - exitCode = 1; + .option("--config ", "Explicit path to .stackctl config file.") + .option("--override ", "Comma-separated list of override files to apply.") + .option("--dry-run", "Compare and report planned actions without executing.") + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const dryRun = options.dryRun as boolean | undefined; + const skipGenerate = options.skipGenerate as boolean | undefined; + const skipUnchanged = options.skipUnchanged as boolean | undefined; + const followLogs = options.followLogs as boolean | undefined; + const configPath = options.config as string | undefined; + + // forceServiceUpdate: CLI false > CLI true > absent (uses config default) + const forceServiceUpdate = options.forceServiceUpdate !== undefined + ? (options.forceServiceUpdate as boolean) + : options.noForceServiceUpdate !== undefined + ? false + : undefined; + + const stacks = options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : undefined; + + const overrides = options.override + ? (options.override as string).split(",").map((s: string) => s.trim()) + : undefined; + + const config = await resolveConfig({ + configPath, + profile, + cwd: Deno.cwd(), + }); + + const runner = new RealProcessRunner(dryRun ?? false); + + const results = await reloadStacks({ + config, + runner, + stacks, + skipGenerate, + skipUnchanged, + dryRun, + followLogs, + forceServiceUpdate, + profile, + overrides, + }); + + // Report results + for (const r of results) { + const icon = r.action === "deployed" + ? "✓" + : r.action === "unchanged" + ? "·" + : r.action === "would-deploy" + ? "[dry-run] would deploy" + : r.action === "would-skip" + ? "[dry-run] unchanged" + : "✗"; + console.log(`${icon} ${r.stack}`); + if (r.error) console.error(` error: ${r.error}`); + } + + if (results.some((r: ReloadResult) => r.action === "error")) { + Deno.exit(ExitCode.DriftOrValidation); + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + Deno.exit(ExitCode.UnexpectedError); + } }); // --- secrets (issue #7) --- diff --git a/src/compose/reload.ts b/src/compose/reload.ts new file mode 100644 index 0000000..47397cd --- /dev/null +++ b/src/compose/reload.ts @@ -0,0 +1,394 @@ +/** + * Reload pipeline — config-first, change-aware re-deployment. + * + * Unlike `stackctl up` (which runs the full generate→render→deploy pipeline + * unconditionally), `reload` is an in-place update tool. By default it + * deploys **every selected stack** (generate → render → deploy). + * + * Opt-in checksum behaviour: pass `--skip-unchanged` to avoid deploying + * stacks whose rendered output matches the previously written file on disk. + * + * Option precedence (highest to lowest): + * CLI flag > active profile config > base config > built-in default + * + * Safety: reload only deploys/updates. It never schedules `docker stack rm`, + * `docker network rm`, or `docker volume rm` commands. + * + * Pipeline: generate → override → render → [checksum?] → deploy → [force-update?] + */ +import { join } from "@std/path"; +import { parse as parseYaml } from "@std/yaml"; +import { stringify as stringifyYaml } from "@std/yaml"; +import { exists } from "@std/fs"; +import { generateStacks } from "./generate.ts"; +import { renderStack } from "../render/mod.ts"; +import { + dockerServiceLogs, + dockerServiceUpdate, + dockerStackDeploy, + dockerStackServices, +} from "../docker/mod.ts"; +import type { ComposeData } from "./types.ts"; +import type { OverrideEntry, ResolvedConfig } from "../config/types.ts"; +import type { ProcessRunner } from "../process/types.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ReloadOptions { + /** Already-resolved configuration (passed from CLI). */ + config: ResolvedConfig; + /** Process runner for Docker commands. */ + runner: ProcessRunner; + /** Stack names to reload (undefined = all from config). */ + stacks?: string[]; + /** Skip stack generation, only re-render and re-deploy from existing files. */ + skipGenerate?: boolean; + /** + * Only re-deploy stacks whose rendered output has changed from the last + * written `.rendered/*.rendered.yml` file. **Default: false** — all + * stacks are re-deployed every time. + * + * Set `--skip-unchanged` to opt in to checksum-based skipping. + */ + skipUnchanged?: boolean; + /** + * Force `docker service update --force` on every service in the stack + * after `docker stack deploy` completes. When unset the value from the + * config file is used; CLI `--force-service-update` / `--no-force-service-update` + * take precedence. + */ + forceServiceUpdate?: boolean; + /** Dry-run: log planned actions without modifying the filesystem or calling Docker. */ + dryRun?: boolean; + /** After deploying, stream `docker service logs` for changed stacks (best-effort). */ + followLogs?: boolean; + /** Active profile name (informational — config is already resolved). */ + profile?: string; + /** Additional override files from CLI (merged with config.overrides). */ + overrides?: (OverrideEntry | string)[]; +} + +export interface ReloadResult { + /** Stack name. */ + stack: string; + /** Action taken. */ + action: "deployed" | "unchanged" | "error" | "would-deploy" | "would-skip"; + /** Error message when action === "error". */ + error?: string; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Run the config-first reload pipeline. + * + * By default every selected stack is re-deployed unconditionally. + * Pass `skipUnchanged: true` to opt in to checksum-based change detection. + * + * 1. Generate stacks (unless --skip-generate) + * 2. Render each stack with env interpolation + * 3. [opt-in] Compute SHA-256 checksum; skip if unchanged + * 4. Deploy (or report would-deploy in dry-run) + * 5. [opt-in] Force `docker service update --force` on deployed services + * 6. [opt-in] Follow logs for deployed stacks + */ +export async function reloadStacks(options: ReloadOptions): Promise { + const { + config, + runner, + stacks: requestedStacks, + skipGenerate, + skipUnchanged, + dryRun, + followLogs, + forceServiceUpdate, + } = options; + + // ── Option precedence: CLI flag > profile config > base config ── + // forceServiceUpdate uses the first defined value: + // CLI flag (boolean) > config.commands.reload.forceServiceUpdate > false (default) + const effectiveForceUpdate = forceServiceUpdate ?? + config.base.commands?.reload?.forceServiceUpdate ?? + false; + + const effectiveRunner = dryRun ? runner.withDryRun(true) : runner; + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + const stacksDir = join(repoRoot, config.base.stack.directory); + const renderDir = join(repoRoot, config.base.render.outputDirectory); + const results: ReloadResult[] = []; + + // Determine target stacks + const stackNames = requestedStacks ?? config.base.stack.names; + + if (stackNames.length === 0) { + return results; + } + + // Merge config-level overrides with CLI-level overrides + const allOverrides: (OverrideEntry | string)[] = [ + ...(config.overrides ?? []), + ...(options.overrides ?? []), + ]; + + // ── 1. Generate stacks (unless skipped) ────────────────────────── + if (!skipGenerate) { + if (dryRun) { + console.log("[dry-run] Step: generate"); + } + + const genResult = await generateStacks({ + stacks: stackNames, + repoRoot, + outputDir: stacksDir, + dryRun: false, // write to stacks/ so render can read them + overrides: allOverrides.length > 0 ? allOverrides : undefined, + }); + + // Report generation errors + for (const err of genResult.errors) { + const name = extractStackFromError(err); + if (name && stackNames.includes(name)) { + results.push({ stack: name, action: "error", error: err }); + } + } + } + + // ── 2. Render, [opt-in compare], and deploy each stack ────────── + for (const stackName of stackNames) { + // Skip stacks that already errored during generation + if (results.some((r) => r.stack === stackName && r.action === "error")) { + continue; + } + + try { + const stackFile = join(stacksDir, `${stackName}.yml`); + + // 2a. Load generated stack YAML from file + let yamlContent: string; + try { + yamlContent = await Deno.readTextFile(stackFile); + } catch { + results.push({ + stack: stackName, + action: "error", + error: `Stack file not found: ${stackFile}. Run "stackctl generate" first.`, + }); + continue; + } + + if (dryRun) { + console.log(`[dry-run] Step: load ${stackName}.yml`); + } + + // 2b. Parse YAML + const parsed = parseYaml(yamlContent) as ComposeData; + + // 2c. Render — resolve ${VAR} placeholders + const renderResult = await renderStack({ + data: parsed, + projectDir: repoRoot, + repoRoot, + strict: true, + }); + + if (dryRun) { + console.log(`[dry-run] Step: render ${stackName}`); + for (const w of renderResult.warnings) { + console.error(`[dry-run] render warning: ${w}`); + } + } + + // 2d. Serialise rendered data to a canonical YAML string + const renderedYaml = `# Rendered by stackctl reload\n${ + stringifyYaml(renderResult.data, { + indent: 2, + lineWidth: 120, + noRefs: true, + } as Record) + }`; + + // Define rendered file path (used by checksum, write, and deploy steps) + const renderedFile = join(renderDir, `${stackName}.rendered.yml`); + + // 2e. [opt-in] Checksum comparison — only when --skip-unchanged + if (skipUnchanged) { + const newChecksum = await computeSha256(renderedYaml); + + if (dryRun) { + console.log( + `[dry-run] Step: checksum ${stackName} (sha256) ⋯ ${newChecksum.slice(0, 12)}…`, + ); + } + + const unchanged = await unchangedCheck(renderedFile, newChecksum); + + if (unchanged) { + if (dryRun) { + console.log(`[dry-run] checksum matches previous — skipping`); + } + results.push({ + stack: stackName, + action: dryRun ? "would-skip" : "unchanged", + }); + continue; + } + } + + // 2f. Write the new rendered file + if (!dryRun) { + try { + await Deno.mkdir(renderDir, { recursive: true }); + await Deno.writeTextFile(renderedFile, renderedYaml); + } catch (err: unknown) { + results.push({ + stack: stackName, + action: "error", + error: `Failed to write rendered file: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + continue; + } + } else { + console.log(`[dry-run] Step: would write ${renderedFile}`); + } + + // 2g. Deploy (or report would-deploy) + if (dryRun) { + console.log( + `[dry-run] Step: would deploy ${stackName}` + + ` → docker stack deploy --compose-file .rendered/${stackName}.rendered.yml ${stackName}`, + ); + results.push({ stack: stackName, action: "would-deploy" }); + } else { + const deployResult = await dockerStackDeploy( + effectiveRunner, + stackName, + renderedFile, + { prune: false, resolveImage: "always" }, + ); + + if (deployResult.success) { + results.push({ stack: stackName, action: "deployed" }); + + // 2h. [opt-in] Force service update after deploy + if (effectiveForceUpdate) { + if (dryRun) { + console.log(`[dry-run] Step: would force-update services for ${stackName}`); + } else { + try { + const svcResult = await dockerStackServices(effectiveRunner, stackName); + if (svcResult.success) { + const lines = svcResult.stdout.trim().split("\n").filter(Boolean); + for (const line of lines) { + try { + const svc = JSON.parse(line); + if (svc.Name) { + await dockerServiceUpdate(effectiveRunner, svc.Name, { force: true }); + } + } catch { /* skip malformed JSON */ } + } + } + } catch { /* force-update is best-effort */ } + } + } + } else { + results.push({ + stack: stackName, + action: "error", + error: deployResult.stderr || "Deployment failed", + }); + } + } + } catch (err: unknown) { + results.push({ + stack: stackName, + action: "error", + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // ── 3. Follow logs for deployed stacks (best-effort) ──────────── + if (followLogs && !dryRun) { + const deployed = results.filter((r) => r.action === "deployed"); + if (deployed.length > 0) { + const realRunner = runner.withDryRun(false); + for (const s of deployed) { + try { + const svcResult = await dockerStackServices(realRunner, s.stack); + if (svcResult.success) { + const lines = svcResult.stdout.trim().split("\n").filter(Boolean); + for (const line of lines) { + try { + const svc = JSON.parse(line); + if (svc.Name) { + await dockerServiceLogs(realRunner, svc.Name, { + follow: true, + tail: 10, + }); + } + } catch { /* skip malformed JSON */ } + } + } + } catch { /* logs are best-effort */ } + } + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Compute a SHA-256 hex digest of a UTF-8 string. + * + * Only called when `skipUnchanged` is enabled (opt-in). + */ +async function computeSha256(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Check whether the new rendered content is byte-identical to the existing + * rendered file on disk. + * + * Only called when `skipUnchanged` is enabled (opt-in). + * + * Returns `true` when the file exists AND its content produces the same + * SHA-256 as `newChecksum`. Returns `false` when the file is absent or + * differs. + */ +async function unchangedCheck( + filePath: string, + newChecksum: string, +): Promise { + try { + if (!(await exists(filePath))) return false; + const existing = await Deno.readTextFile(filePath); + const existingChecksum = await computeSha256(existing); + return existingChecksum === newChecksum; + } catch { + return false; + } +} + +/** + * Extract a stack name from a generateStacks error message of the form + * `Stack "name": reason`. + */ +function extractStackFromError(errorMsg: string): string | null { + const match = errorMsg.match(/Stack\s+"([^"]+)"/); + return match ? match[1] : null; +} diff --git a/src/compose/reload_test.ts b/src/compose/reload_test.ts new file mode 100644 index 0000000..2853a0b --- /dev/null +++ b/src/compose/reload_test.ts @@ -0,0 +1,943 @@ +/** + * Tests for the config-first reload pipeline. + * + * Uses FakeProcessRunner — never talks to real Docker. + * + * Coverage: + * - Default always-deploys (no checksum skip) + * - Opt-in --skip-unchanged + * - Dry-run logging + * - Force-service-update + * - Safety: no remove commands are ever scheduled + */ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { FakeProcessRunner, FakeProcessRunnerBuilder } from "../testing/fakes.ts"; +import { reloadStacks } from "./reload.ts"; +import type { ResolvedConfig } from "../config/types.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface StackEntry { + name: string; + content: string; + /** If true, also write a pre-existing .rendered file with same content. */ + preRendered?: boolean; + /** If set, pre-existing .rendered file has this content (for diff detection). */ + preRenderedContent?: string; +} + +/** + * Set up a temporary directory with: + * - A `.stackctl` config + * - Service compose files (x-stack tagged) + * - Generated stack files in `stacks/` + * - Optionally pre-rendered files in `.rendered/` + */ +async function setupProject(entries: StackEntry[]): Promise<{ + tmp: string; + config: ResolvedConfig; + cleanup: () => Promise; +}> { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-reload-test-" }); + + // Write .stackctl config + const stackNames = entries.map((e) => e.name); + const configYaml = [ + "project: test-project", + "stack:", + " directory: stacks", + ` names: [${stackNames.join(", ")}]`, + " network: traefik-public", + "render:", + " outputDirectory: .rendered", + "env:", + " activeName: .env", + ].join("\n"); + await Deno.writeTextFile(`${tmp}/.stackctl`, configYaml); + + // Write service compose files with x-stack + for (const entry of entries) { + const svcDir = `${tmp}/services/${entry.name}-svc`; + await Deno.mkdir(svcDir, { recursive: true }); + await Deno.writeTextFile( + `${svcDir}/docker-compose.yml`, + [ + `x-stack: ${entry.name}`, + "services:", + ` ${entry.name}-svc:`, + " image: nginx:alpine", + ` content: "${entry.content}"`, + ].join("\n"), + ); + } + + // Write generated stacks to stacks/ dir (simulating prior generate) + const stacksDir = `${tmp}/stacks`; + await Deno.mkdir(stacksDir, { recursive: true }); + for (const entry of entries) { + const generated = [ + "# Generated by stackctl generate — do not edit manually.", + "services:", + ` ${entry.name}-svc:`, + " image: nginx:alpine", + ` content: "${entry.content}"`, + "networks:", + " default:", + " name: traefik-public", + " external: true", + ].join("\n"); + await Deno.writeTextFile(`${stacksDir}/${entry.name}.yml`, generated); + } + + // Optionally write pre-rendered files + const renderedDir = `${tmp}/.rendered`; + for (const entry of entries) { + if (entry.preRendered) { + await Deno.mkdir(renderedDir, { recursive: true }); + const content = entry.preRenderedContent ?? [ + `# Rendered by stackctl reload`, + "services:", + ` ${entry.name}-svc:`, + " image: nginx:alpine", + ` content: "${entry.content}"`, + "networks:", + " default:", + " name: traefik-public", + " external: true", + ].join("\n"); + await Deno.writeTextFile( + `${renderedDir}/${entry.name}.rendered.yml`, + content, + ); + } + } + + // Build a minimal ResolvedConfig pointing to the temp dir + const config: ResolvedConfig = { + base: { + project: "test-project", + repoRoot: tmp, + stack: { + directory: "stacks", + names: stackNames, + network: "traefik-public", + }, + render: { outputDirectory: ".rendered" }, + env: { activeName: ".env" }, + }, + overrides: [], + }; + + return { + tmp, + config, + cleanup: async () => { + await Deno.remove(tmp, { recursive: true }); + }, + }; +} + +/** Create a FakeProcessRunner that responds to docker stack deploy with success. */ +function deploySuccessRunner(): FakeProcessRunner { + return new FakeProcessRunnerBuilder() + .addResponse({ + match: ["docker", "stack", "deploy"], + result: { stdout: "deploying...", stderr: "", code: 0, success: true, command: [] }, + }) + .build(); +} + +/** + * Create a runner that supports docker stack deploy AND docker stack services (JSON). + */ +function deployAndServicesRunner(): FakeProcessRunner { + return new FakeProcessRunnerBuilder() + .addResponse({ + match: ["docker", "stack", "deploy"], + result: { stdout: "deploying...", stderr: "", code: 0, success: true, command: [] }, + }) + .addResponse({ + match: ["docker", "stack", "services"], + result: { + stdout: `{"Name":"platform-svc","Mode":"replicated"}\n`, + stderr: "", + code: 0, + success: true, + command: [], + }, + }) + .addResponse({ + match: ["docker", "service", "update"], + result: { stdout: "updated", stderr: "", code: 0, success: true, command: [] }, + }) + .build(); +} + +// --------------------------------------------------------------------------- +// Issue 1: Default always-deploys (no checksum skip) +// --------------------------------------------------------------------------- + +Deno.test("reload: default always deploys even when rendered output unchanged", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner1 = deploySuccessRunner(); + const runner2 = deploySuccessRunner(); + + try { + // First run: deploys and writes the .rendered file + await reloadStacks({ config, runner: runner1, skipGenerate: true }); + assertEquals(runner1.containsCommand(["docker", "stack", "deploy"]), true); + + // Second run: by default should STILL deploy (no checksum skip) + const results = await reloadStacks({ + config, + runner: runner2, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "deployed"); + assertEquals(runner2.containsCommand(["docker", "stack", "deploy"]), true); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Issue 1: Opt-in checksum skipping with --skip-unchanged +// --------------------------------------------------------------------------- + +Deno.test("reload: skipUnchanged=true skips stacks whose rendered output is unchanged", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + // First run: deploys and writes the .rendered file + await reloadStacks({ config, runner: deploySuccessRunner(), skipGenerate: true }); + + // Second run with skipUnchanged=true should detect unchanged + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + skipUnchanged: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "unchanged"); + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), false); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: skipUnchanged=true deploys when content has changed", async () => { + const { config, cleanup } = await setupProject([ + { + name: "platform", + content: "v1", + preRendered: true, + preRenderedContent: "# old content", + }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + skipUnchanged: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), true); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: skipUnchanged=true dry-run shows would-skip for unchanged stacks", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + + try { + // First run: deploy to write the .rendered file + await reloadStacks({ + config, + runner: deploySuccessRunner(), + skipGenerate: true, + }); + + // Second run: dry-run with skipUnchanged should show would-skip + const results = await reloadStacks({ + config, + runner: deploySuccessRunner(), + dryRun: true, + skipGenerate: true, + skipUnchanged: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "would-skip"); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Issue 4: Safety — no remove commands ever scheduled +// --------------------------------------------------------------------------- + +Deno.test("reload: never schedules docker stack rm commands", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + await reloadStacks({ config, runner, skipGenerate: true }); + + // Assert no docker stack rm command was recorded + assertEquals(runner.containsCommand(["docker", "stack", "rm"]), false); + assertEquals(runner.containsCommand(["docker", "network", "rm"]), false); + assertEquals(runner.containsCommand(["docker", "volume", "rm"]), false); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: never schedules remove commands even in dry-run", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + await reloadStacks({ + config, + runner, + skipGenerate: true, + dryRun: true, + }); + + // Assert no remove commands + assertEquals(runner.containsCommand(["docker", "stack", "rm"]), false); + assertEquals(runner.containsCommand(["docker", "network", "rm"]), false); + assertEquals(runner.containsCommand(["docker", "volume", "rm"]), false); + + // But deploy command should not have been executed either + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), false); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: deploys when no existing rendered file", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + // Ensure no .rendered directory exists + try { + await Deno.remove(`${tmp}/.rendered`, { recursive: true }); + } catch { /* ok */ } + + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "deployed"); + + // Verify rendered file was created + const rendered = await Deno.readTextFile(`${tmp}/.rendered/platform.rendered.yml`); + assertStringIncludes(rendered, "platform-svc"); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Issue 5: Dry-run shows each step +// --------------------------------------------------------------------------- + +Deno.test("reload: dry-run shows changes without deploying", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + dryRun: true, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "would-deploy"); + // In dry-run, no docker deploy should have been attempted + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), false); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: dry-run unchanged followed by real run deploys", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1", preRendered: true, preRenderedContent: "# old" }, + ]); + + try { + // First: dry-run should say would-deploy (no checksum by default) + const dryResults = await reloadStacks({ + config, + runner: deploySuccessRunner(), + dryRun: true, + skipGenerate: true, + }); + assertEquals(dryResults[0].action, "would-deploy"); + + // Second: real run should deploy + const realRunner = deploySuccessRunner(); + const results = await reloadStacks({ + config, + runner: realRunner, + skipGenerate: true, + }); + assertEquals(results[0].action, "deployed"); + assertEquals(realRunner.containsCommand(["docker", "stack", "deploy"]), true); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Existing tests (adapted for new default behavior) +// --------------------------------------------------------------------------- + +Deno.test("reload: changed stacks are deployed", async () => { + const { config, tmp, cleanup } = await setupProject([ + { + name: "platform", + content: "v1", + preRendered: true, + preRenderedContent: "# different content", + }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), true); + + // Verify the .rendered file was updated + const rendered = await Deno.readTextFile(`${tmp}/.rendered/platform.rendered.yml`); + assertStringIncludes(rendered, "Rendered by stackctl reload"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: skip-generate uses existing stack files", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "deployed"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: generates when skip-generate is false", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: false, + }); + + assertEquals(results.length, 1); + assert(results[0].action === "deployed" || results[0].action === "error"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: stacks filter only reloads requested stacks", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + { name: "infra", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + // Reload only "platform" + const results = await reloadStacks({ + config, + runner, + stacks: ["platform"], + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "platform"); + assertEquals(results[0].action, "deployed"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: handles missing rendered directory", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + // Ensure no .rendered directory exists + try { + await Deno.remove(`${tmp}/.rendered`, { recursive: true }); + } catch { /* ok */ } + + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "deployed"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: deployment failure is reported as error", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = new FakeProcessRunnerBuilder() + .addResponse({ + match: ["docker", "stack", "deploy"], + result: { + stdout: "", + stderr: "deploy failed: network error", + code: 1, + success: false, + command: [], + }, + }) + .build(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "error"); + assertEquals(results[0].error, "deploy failed: network error"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: reports error for missing stack file", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + // Remove the generated stack file + await Deno.remove(`${tmp}/stacks/platform.yml`); + + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "error"); + assertStringIncludes(results[0].error!, "Stack file not found"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: handles multiple stacks with mixed results", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + { name: "infra", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + try { + // First run: deploy both stacks to write .rendered files + await reloadStacks({ + config, + runner: deploySuccessRunner(), + skipGenerate: true, + }); + + // Modify the generated stack file for "infra" so it differs on next render + const infraStackFile = `${tmp}/stacks/infra.yml`; + const origContent = await Deno.readTextFile(infraStackFile); + await Deno.writeTextFile(infraStackFile, origContent.replace('content: "v1"', 'content: "v2"')); + + // Second run: both should deploy (default behavior) + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 2); + + const platform = results.find((r) => r.stack === "platform")!; + const infra = results.find((r) => r.stack === "infra")!; + + // Default behavior: always deploys + assertEquals(platform.action, "deployed"); + assertEquals(infra.action, "deployed"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: handles empty stack list gracefully", async () => { + const { config, cleanup } = await setupProject([]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 0); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: with overrides passed from CLI", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + // Write an override file + const overridePath = `${config.base.repoRoot}/override.yml`; + await Deno.writeTextFile(overridePath, "services:\n extra: {}\n"); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: false, + overrides: [{ source: "explicit" as const, path: overridePath }], + }); + + assertEquals(results.length, 1); + assert(results[0].action === "deployed" || results[0].action === "error"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: handles generation errors gracefully", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + await Deno.remove(`${tmp}/services`, { recursive: true }); + await Deno.remove(`${tmp}/stacks`, { recursive: true }); + + const runner = deploySuccessRunner(); + + try { + const cfg: ResolvedConfig = { + base: { + ...config.base, + stack: { ...config.base.stack, names: ["nonexistent"] }, + }, + overrides: [], + }; + + const results = await reloadStacks({ + config: cfg, + runner, + skipGenerate: false, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].stack, "nonexistent"); + assertEquals(results[0].action, "error"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: verifies rendered file content after deploy", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "after-reload" }, + ]); + const runner = deploySuccessRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results[0].action, "deployed"); + + const rendered = await Deno.readTextFile(`${tmp}/.rendered/platform.rendered.yml`); + assertStringIncludes(rendered, "Rendered by stackctl reload"); + assertStringIncludes(rendered, "platform-svc"); + assertStringIncludes(rendered, "after-reload"); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: multiple deployments with only partial changes", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "alpha", content: "v1" }, + { name: "beta", content: "old" }, + { name: "gamma", content: "v3" }, + ]); + const runner = deploySuccessRunner(); + + try { + // First run: deploy all three stacks so .rendered files are written + await reloadStacks({ + config, + runner: deploySuccessRunner(), + skipGenerate: true, + }); + + // Modify only "beta"'s generated stack file + const betaStackFile = `${tmp}/stacks/beta.yml`; + const origContent = await Deno.readTextFile(betaStackFile); + await Deno.writeTextFile( + betaStackFile, + origContent.replace('content: "old"', 'content: "new"'), + ); + + // Second run: default behavior always deploys all + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + }); + + assertEquals(results.length, 3); + assertEquals(results.find((r) => r.stack === "alpha")!.action, "deployed"); + assertEquals(results.find((r) => r.stack === "beta")!.action, "deployed"); + assertEquals(results.find((r) => r.stack === "gamma")!.action, "deployed"); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Issue 3: Force service update +// --------------------------------------------------------------------------- + +Deno.test("reload: forceServiceUpdate calls docker service update --force", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deployAndServicesRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + forceServiceUpdate: true, + }); + + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "service", "update", "--force"]), true); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: forceServiceUpdate=false does not call docker service update", async () => { + const { config, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deployAndServicesRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + forceServiceUpdate: false, + }); + + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "service", "update"]), false); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: forceServiceUpdate respects config base.commands.reload.forceServiceUpdate", async () => { + const { config: baseConfig, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + + const config: ResolvedConfig = { + ...baseConfig, + base: { + ...baseConfig.base, + commands: { + reload: { forceServiceUpdate: true }, + }, + }, + }; + + const runner = deployAndServicesRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + // Not setting forceServiceUpdate — should come from config + }); + + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "service", "update", "--force"]), true); + } finally { + await cleanup(); + } +}); + +Deno.test("reload: CLI forceServiceUpdate overrides config", async () => { + const { config: baseConfig, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + + // Config says force=true, but CLI says force=false + const config: ResolvedConfig = { + ...baseConfig, + base: { + ...baseConfig.base, + commands: { + reload: { forceServiceUpdate: true }, + }, + }, + }; + + const runner = deployAndServicesRunner(); + + try { + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + forceServiceUpdate: false, // CLI flag overrides config + }); + + assertEquals(results[0].action, "deployed"); + assertEquals(runner.containsCommand(["docker", "service", "update"]), false); + } finally { + await cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// Edge case: checksum comparison ignores header comments +// (still valid for skipUnchanged mode) +// --------------------------------------------------------------------------- + +Deno.test("reload: skipUnchanged checksum comparison is byte-level", async () => { + const { config, tmp, cleanup } = await setupProject([ + { name: "platform", content: "v1" }, + ]); + const runner = deploySuccessRunner(); + + // Write a pre-rendered file with a different header but same body + await Deno.mkdir(`${tmp}/.rendered`, { recursive: true }); + await Deno.writeTextFile( + `${tmp}/.rendered/platform.rendered.yml`, + [ + "# Completely different header from old version", + "services:", + " platform-svc:", + " image: nginx:alpine", + ` content: "v1"`, + "networks:", + " default:", + " name: traefik-public", + " external: true", + ].join("\n"), + ); + + try { + // Different header → different checksum → should deploy when skipUnchanged=true + const results = await reloadStacks({ + config, + runner, + skipGenerate: true, + skipUnchanged: true, + }); + + assertEquals(results.length, 1); + assertEquals(results[0].action, "deployed"); + } finally { + await cleanup(); + } +}); diff --git a/src/docker/mod.ts b/src/docker/mod.ts index 63ab0b9..0299e47 100644 --- a/src/docker/mod.ts +++ b/src/docker/mod.ts @@ -18,6 +18,23 @@ export interface DockerLogsOptions { timestamps?: boolean; } +export interface DockerServiceUpdateOptions { + force?: boolean; + image?: string; +} + +export function dockerServiceUpdate( + runner: ProcessRunner, + serviceName: string, + opts?: DockerServiceUpdateOptions, +): Promise { + const cmd = ["docker", "service", "update"]; + if (opts?.force) cmd.push("--force"); + if (opts?.image) cmd.push("--image", opts.image); + cmd.push(serviceName); + return runner.run(cmd); +} + export function dockerStackDeploy( runner: ProcessRunner, stackName: string,