diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 4a5c08e..4cb42d4 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -4,11 +4,26 @@ import { initConfig } from "../config/mod.ts"; import { resolveConfig } from "../config/mod.ts"; import { ExitCode } from "../config/types.ts"; import { generateStacks } from "../compose/mod.ts"; +import { discoverComposeFiles } from "../compose/discover.ts"; import type { ComposeData, GenerateOptions } from "../compose/mod.ts"; import { join, resolve } from "@std/path"; import { ensureDir, exists } from "@std/fs"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; import { renderStack } from "../render/mod.ts"; +import { RealProcessRunner } from "../process/runner.ts"; +import { sync as syncValidation } from "../compose/sync.ts"; +import { + dockerComposeConfig, + dockerInfo, + dockerServiceLogs, + dockerStackDeploy, + dockerStackPs, + dockerStackRm, + dockerStackServices, + dockerSwarmStatus, +} from "../docker/mod.ts"; + +let exitCode = 0; /** * Parse and execute CLI commands. @@ -17,7 +32,10 @@ import { renderStack } from "../render/mod.ts"; export async function main(args: string[]): Promise { try { const cmd = await buildCli().parse(args); - return cmd instanceof Error ? 1 : 0; + if (cmd instanceof Error) { + exitCode = 1; + } + return exitCode; } catch (err) { console.error(err instanceof Error ? err.message : String(err)); return 1; @@ -44,7 +62,7 @@ export function buildCli(): Command { // Default action: show help when no subcommand matches cli.action(() => { cli.showHelp(); - Deno.exit(0); + exitCode = 0; }); // --- init (issue #3) --- @@ -67,7 +85,6 @@ export function buildCli(): Command { detect, preset, profile, - writeGitignore, force, dryRun, cwd: Deno.cwd(), @@ -78,7 +95,8 @@ export function buildCli(): Command { } if (result.errors.length > 0) { - Deno.exit(2); // ExitCode.UserConfigError + exitCode = ExitCode.UserConfigError; + return; } if (dryRun) { @@ -142,7 +160,8 @@ export function buildCli(): Command { for (const e of result.errors) { console.error(`error: ${e}`); } - Deno.exit(ExitCode.DriftOrValidation); + exitCode = ExitCode.DriftOrValidation; + return; } if (dryRun) { @@ -157,7 +176,7 @@ export function buildCli(): Command { } } catch (err: unknown) { console.error(`error: ${err instanceof Error ? err.message : String(err)}`); - Deno.exit(ExitCode.UnexpectedError); + exitCode = ExitCode.UnexpectedError; } }); @@ -201,7 +220,8 @@ export function buildCli(): Command { if (genResult.errors.length > 0) { for (const e of genResult.errors) console.error(`error: ${e}`); - Deno.exit(ExitCode.DriftOrValidation); + exitCode = ExitCode.DriftOrValidation; + return; } // 2. Render each generated stack @@ -253,38 +273,225 @@ export function buildCli(): Command { } if (strict && hasUnresolved) { - Deno.exit(ExitCode.DriftOrValidation); + exitCode = ExitCode.DriftOrValidation; } } catch (err: unknown) { console.error(`error: ${err instanceof Error ? err.message : String(err)}`); - Deno.exit(ExitCode.UnexpectedError); + exitCode = ExitCode.UnexpectedError; } }); // --- up (issue #6) --- cli.command("up", "Deploy stacks to Docker Swarm.") - .option("--no-logs", "Do not follow logs after deploy.") + .option("--follow-logs", "Follow logs after deploy.") .option("--dry-run", "Print planned actions without executing.") - .option("--skip-generate", "Skip stack generation step.") - .option("--allow-unrendered", "Deploy unrendered stack files (not recommended).") + .option("--detach", "Exit immediately without waiting for services to converge.") + .option("--prune", "Prune obsolete services.") .option("--stacks ", "Comma-separated list of stack names to deploy.") .option("--profile ", "Use a specific profile.") .option("--override ", "Comma-separated list of override files.") - .action(() => { - console.error("up: not yet implemented (issue #6)"); - Deno.exit(1); + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const dryRun = options.dryRun as boolean | undefined; + const followLogs = options.followLogs as boolean | undefined; + const detach = options.detach as boolean | undefined; + const prune = options.prune as boolean | 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({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + + const runner = new RealProcessRunner(dryRun ?? false); + + // 1. Discover or use specified stacks + const discovery = await discoverComposeFiles({ repoRoot }); + const targetStacks = stacks ?? Object.keys(discovery.stacks); + + if (targetStacks.length === 0) { + console.error("error: No stacks discovered."); + exitCode = ExitCode.DriftOrValidation; + return; + } + + // 2. Generate stacks in memory + const genResult = await generateStacks({ + stacks: targetStacks, + repoRoot, + outputDir: undefined, + dryRun: true, + overrides: overrides, + }); + + for (const w of genResult.warnings) console.error(`warning: ${w}`); + for (const e of genResult.errors) console.error(`error: ${e}`); + + if (genResult.errors.length > 0) { + exitCode = ExitCode.DriftOrValidation; + return; + } + + // 3. Render and deploy each stack + let deployFailed = false; + const deployedStacks: string[] = []; + + for (const [stackName, yamlContent] of Object.entries(genResult.generated)) { + try { + const parsed = parseYaml(yamlContent) as ComposeData; + const renderResult = await renderStack({ + data: parsed, + projectDir: repoRoot, + repoRoot, + strict: true, + }); + + for (const w of renderResult.warnings) { + console.error(`warning: [${stackName}] ${w}`); + } + + if (dryRun) { + console.log(`[dry-run] would deploy: ${stackName}`); + deployedStacks.push(stackName); + continue; + } + + // Write rendered YAML to temp file for docker stack deploy + const tempFile = await Deno.makeTempFile({ suffix: ".yml" }); + try { + const yaml = stringifyYaml(renderResult.data, { + indent: 2, + lineWidth: 120, + noRefs: true, + } as Record); + await Deno.writeTextFile(tempFile, yaml); + + const deployResult = await dockerStackDeploy(runner, stackName, tempFile, { + prune, + detach, + resolveImage: "always", + }); + + if (deployResult.success) { + console.log(`Deployed: ${stackName}`); + deployedStacks.push(stackName); + } else { + console.error( + `error deploying ${stackName}: ${deployResult.stderr || "failed"}`, + ); + deployFailed = true; + } + } finally { + try { + await Deno.remove(tempFile); + } catch { /* ignore */ } + } + } catch (err: unknown) { + console.error( + `error: [${stackName}] ${err instanceof Error ? err.message : String(err)}`, + ); + deployFailed = true; + } + } + + if (deployFailed) { + exitCode = ExitCode.DriftOrValidation; + return; + } + + // 4. Follow logs after deploy if requested + if (followLogs && !dryRun && deployedStacks.length > 0) { + console.log("\n--- Following logs (Ctrl-C to stop) ---"); + const logRunner = new RealProcessRunner(false); + for (const stackName of deployedStacks) { + try { + const svcResult = await dockerStackServices(logRunner, 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) { + console.log(`\n=== ${svc.Name} ===`); + await dockerServiceLogs(logRunner, svc.Name, { + follow: true, + tail: 10, + }); + } + } catch { /* skip malformed JSON lines */ } + } + } + } catch { /* logs are best-effort */ } + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- down (issue #6) --- - cli.command("down", "Remove stacks from Docker Swarm.") + cli.command( + "down", + "Remove Docker Swarm stacks from the cluster.\n" + + "WARNING: This is a destructive operation. Running services, networks,\n" + + "and associated resources will be removed. Use --dry-run to preview\n" + + "without executing, and --yes to skip the confirmation prompt.", + ) .option("--yes", "Skip confirmation prompt.") .option("--dry-run", "Print planned actions without executing.") - .option("--remove-network", "Also remove the configured overlay network.") .option("--stacks ", "Comma-separated list of stack names to remove.") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("down: not yet implemented (issue #6)"); - Deno.exit(1); + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const dryRun = options.dryRun as boolean | undefined; + const skipConfirm = options.yes as boolean | undefined; + + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + + const discovery = await discoverComposeFiles({ repoRoot }); + const targetStacks = options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : Object.keys(discovery.stacks); + + if (targetStacks.length === 0) { + console.log("No stacks to remove."); + return; + } + + // Confirmation prompt + if (!dryRun && !skipConfirm) { + console.log("The following stacks will be removed:"); + for (const s of targetStacks) console.log(` - ${s}`); + const answer = prompt("Proceed? [y/N] "); + if (!answer || answer.toLowerCase() !== "y") { + console.log("Aborted."); + return; + } + } + + const runner = new RealProcessRunner(dryRun ?? false); + + for (const stackName of targetStacks) { + const result = await dockerStackRm(runner, stackName); + if (result.success) { + console.log(`${dryRun ? "[dry-run] would remove" : "Removed"}: ${stackName}`); + } else { + console.error(`error removing ${stackName}: ${result.stderr || "failed"}`); + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- status (issue #6) --- @@ -292,9 +499,69 @@ export function buildCli(): Command { .option("--json", "Output JSON machine-readable status.") .option("--stacks ", "Comma-separated list of stack names.") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("status: not yet implemented (issue #6)"); - Deno.exit(1); + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const jsonOutput = options.json as boolean | undefined; + + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + + const discovery = await discoverComposeFiles({ repoRoot }); + const targetStacks = options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : Object.keys(discovery.stacks); + + if (targetStacks.length === 0) { + console.log(jsonOutput ? "{}" : "No stacks discovered."); + return; + } + + const runner = new RealProcessRunner(false); + const statusResult: Record = {}; + + for (const stackName of targetStacks) { + if (jsonOutput) { + const svcResult = await dockerStackServices(runner, stackName); + const psResult = await dockerStackPs(runner, stackName); + + const services: unknown[] = []; + if (svcResult.success) { + for (const line of svcResult.stdout.trim().split("\n").filter(Boolean)) { + try { + services.push(JSON.parse(line)); + } catch { /* skip */ } + } + } + + const tasks: unknown[] = []; + if (psResult.success) { + for (const line of psResult.stdout.trim().split("\n").filter(Boolean)) { + try { + tasks.push(JSON.parse(line)); + } catch { /* skip */ } + } + } + + statusResult[stackName] = { services, tasks }; + } else { + console.log(`\n=== ${stackName} ===`); + const svcResult = await dockerStackServices(runner, stackName); + if (svcResult.success) { + console.log(svcResult.stdout || " (no services)"); + } else { + console.error(` error: ${svcResult.stderr || "failed to list services"}`); + } + } + } + + if (jsonOutput) { + console.log(JSON.stringify(statusResult, null, 2)); + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- logs (issue #6) --- @@ -302,9 +569,59 @@ export function buildCli(): Command { .arguments("[services...:string]") .option("--stacks ", "Comma-separated list of stack names.") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("logs: not yet implemented (issue #6)"); - Deno.exit(1); + .option("--follow", "Follow log output (default: true).") + .option("--tail ", "Number of lines from end (default: all).") + .action(async (options: Record, ...serviceArgs: string[]) => { + try { + const profile = options.profile as string | undefined; + const follow = options.follow !== false; + const tail = options.tail as number | undefined; + const services = serviceArgs.length > 0 ? serviceArgs : undefined; + + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + + const runner = new RealProcessRunner(false); + + // If explicit services provided, tail them directly + if (services && services.length > 0) { + for (const svc of services) { + console.log(`=== ${svc} ===`); + await dockerServiceLogs(runner, svc, { follow, tail }); + } + return; + } + + // Otherwise discover stacks and tail all services + const stacks = options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : undefined; + + const discovery = await discoverComposeFiles({ repoRoot }); + const targetStacks = stacks ?? Object.keys(discovery.stacks); + + for (const stackName of targetStacks) { + const svcResult = await dockerStackServices(runner, stackName); + if (!svcResult.success) { + console.error(`error listing services for ${stackName}: ${svcResult.stderr}`); + continue; + } + + const lines = svcResult.stdout.trim().split("\n").filter(Boolean); + for (const line of lines) { + try { + const svc = JSON.parse(line); + if (svc.Name) { + console.log(`=== ${svc.Name} ===`); + await dockerServiceLogs(runner, svc.Name, { follow, tail }); + } + } catch { /* skip */ } + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- sync (issue #6) --- @@ -312,9 +629,43 @@ export function buildCli(): Command { .option("--quiet", "Suppress diff output.") .option("--non-interactive", "Skip confirmation; exit 1 on drift.") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("sync: not yet implemented (issue #6)"); - Deno.exit(1); + .option("--stacks ", "Comma-separated list of stack names.") + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const quiet = options.quiet as boolean | undefined; + const nonInteractive = options.nonInteractive as boolean | undefined; + const stacks = options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : undefined; + + const result = await syncValidation({ + stacks, + profile, + quiet, + nonInteractive, + }); + + for (const w of result.warnings) console.error(`warning: ${w}`); + for (const e of result.errors) console.error(`error: ${e}`); + + if (!result.match) { + if (!quiet) { + for (const [stackName, diff] of Object.entries(result.diffs)) { + if (diff) { + console.log(`\n--- ${stackName} diff ---`); + console.log(diff); + } + } + } + exitCode = ExitCode.DriftOrValidation; + } else { + console.log("Sync OK: generated stacks match committed files."); + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- doctor (issue #6) --- @@ -322,9 +673,148 @@ export function buildCli(): Command { .option("--fix-volumes", "Create missing external volumes.") .option("--check-secrets", "Also check for secrets tooling (sops, age).") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("doctor: not yet implemented (issue #6)"); - Deno.exit(1); + .action(async (options: Record) => { + const issues: string[] = []; + const checks: string[] = []; + + const profile = options.profile as string | undefined; + const checkSecrets = options.checkSecrets as boolean | undefined; + + const runner = new RealProcessRunner(false); + + // 1. Check Docker installed and running + checks.push("Docker installed and running..."); + try { + const infoResult = await dockerInfo(runner); + if (infoResult.success) { + checks.push(" \u2713 Docker is running"); + } else { + issues.push("Docker is not running or not accessible."); + } + } catch { + issues.push("Docker command not found. Is Docker installed?"); + } + + // 2. Check Docker Swarm mode + checks.push("Docker Swarm mode..."); + try { + const swarm = await dockerSwarmStatus(runner); + if (swarm.active) { + checks.push( + ` \u2713 Swarm mode active${swarm.nodeId ? ` (node: ${swarm.nodeId})` : ""}`, + ); + } else { + issues.push("Docker is not in Swarm mode. Run: docker swarm init"); + } + } catch { + issues.push("Could not determine Swarm status."); + } + + // 3. Check config file exists and is valid + checks.push("Config file..."); + try { + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + checks.push(` \u2713 Config resolved (profile: ${config.profile ?? "default"})`); + checks.push(` Project: ${config.base.project || "(unnamed)"}`); + checks.push(` Stack directory: ${config.base.stack.directory}`); + checks.push(` Stack names: ${config.base.stack.names.join(", ") || "(none)"}`); + + // Check override files referenced in config exist + for (const override of config.overrides) { + const existsInFs = await exists(override.path); + if (!existsInFs) { + issues.push(`Override file not found: ${override.path}`); + } else { + checks.push(` \u2713 Override: ${override.path}`); + } + } + + // Render path validation + checks.push("Render path..."); + const repoRootPath = config.base.repoRoot ?? Deno.cwd(); + const renderDir = join(repoRootPath, config.base.render.outputDirectory); + try { + await Deno.stat(renderDir); + checks.push(` \u2713 Render directory exists: ${renderDir}`); + } catch { + try { + await Deno.mkdir(renderDir); + checks.push(` \u2713 Render directory created (and removed): ${renderDir}`); + await Deno.remove(renderDir); + } catch { + issues.push(`Render directory not creatable: ${renderDir}`); + } + } + + // Validate stack files with docker compose config + checks.push("Compose file validation..."); + for (const stackName of config.base.stack.names) { + const composeFile = join( + repoRootPath, + config.base.stack.directory, + `${stackName}.yml`, + ); + try { + await Deno.stat(composeFile); + } catch { + issues.push(`Stack file not found: ${composeFile}`); + continue; + } + + try { + const composeResult = await dockerComposeConfig(runner, composeFile); + if (composeResult.success) { + checks.push(` \u2713 Stack "${stackName}" compose file is valid`); + } else { + issues.push( + `Stack "${stackName}" compose file has errors:\n${composeResult.stderr}`, + ); + } + } catch { + issues.push(`docker compose config failed for stack "${stackName}"`); + } + } + } catch (err: unknown) { + issues.push( + `Config error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // 4. Check sops/age available (if secrets configured) + if (checkSecrets) { + checks.push("Secrets tooling..."); + const sopsOk = await runner.which("sops"); + const ageOk = await runner.which("age"); + if (sopsOk) { + checks.push(" \u2713 sops available"); + } else { + issues.push("sops not found on PATH. Install: https://github.com/getsops/sops"); + } + if (ageOk) { + checks.push(" \u2713 age available"); + } else { + issues.push("age not found on PATH. Install: https://github.com/FiloSottile/age"); + } + } + + // 5. Check for external volumes (if --fix-volumes) + if (options.fixVolumes as boolean | undefined) { + checks.push("External volumes: not yet implemented"); + } + + // Output results + console.log("=== stackctl doctor ===\n"); + for (const c of checks) console.log(c); + console.log(""); + + if (issues.length > 0) { + console.error("Issues found:"); + for (const issue of issues) console.error(` \u2717 ${issue}`); + console.error(`\n${issues.length} issue(s) found.`); + exitCode = ExitCode.MissingDependency; + } else { + console.log("All checks passed."); + } }); // --- reload (issue #9) --- @@ -338,7 +828,7 @@ export function buildCli(): Command { .option("--dry-run", "Print planned actions without executing.") .action(() => { console.error("reload: not yet implemented (issue #9)"); - Deno.exit(1); + exitCode = 1; }); // --- secrets (issue #7) --- @@ -349,7 +839,7 @@ export function buildCli(): Command { .option("--dry-run", "Print planned actions without executing.") .action(() => { console.error("secrets encrypt: not yet implemented (issue #7)"); - Deno.exit(1); + exitCode = 1; }); secretsCmd.command("decrypt", "Decrypt encrypted .env files to plaintext.") .arguments("[services...:string]") @@ -357,7 +847,7 @@ export function buildCli(): Command { .option("--dry-run", "Print planned actions without executing.") .action(() => { console.error("secrets decrypt: not yet implemented (issue #7)"); - Deno.exit(1); + exitCode = 1; }); secretsCmd.command("deploy", "Decrypt and deploy stacks with secret values.") .arguments("[services...:string]") @@ -365,20 +855,20 @@ export function buildCli(): Command { .option("--dry-run", "Print planned actions without executing.") .action(() => { console.error("secrets deploy: not yet implemented (issue #7)"); - Deno.exit(1); + exitCode = 1; }); secretsCmd.command("clean", "Remove plaintext .env files that have encrypted counterparts.") .option("--profile ", "Use a specific profile.") .option("--dry-run", "Print planned actions without executing.") .action(() => { console.error("secrets clean: not yet implemented (issue #7)"); - Deno.exit(1); + exitCode = 1; }); secretsCmd.command("check", "Check secrets tooling availability.") .option("--profile ", "Use a specific profile.") .action(() => { console.error("secrets check: not yet implemented (issue #7)"); - Deno.exit(1); + exitCode = 1; }); // --- env (issue #14) --- @@ -394,7 +884,7 @@ export function buildCli(): Command { .option("--materialize", "Materialize profile preset env values.") .action(() => { console.error("env: not yet implemented (issue #14)"); - Deno.exit(1); + exitCode = 1; }); // --- plan (issue #15) --- @@ -406,7 +896,7 @@ export function buildCli(): Command { .option("--json", "Output machine-readable JSON.") .action(() => { console.error("plan: not yet implemented (issue #15)"); - Deno.exit(1); + exitCode = 1; }); // --- completions (issue #10) --- @@ -414,17 +904,17 @@ export function buildCli(): Command { completionsCmd.command("bash", "Generate bash completion script.") .action(() => { console.error("completions bash: not yet implemented (issue #10)"); - Deno.exit(1); + exitCode = 1; }); completionsCmd.command("zsh", "Generate zsh completion script.") .action(() => { console.error("completions zsh: not yet implemented (issue #10)"); - Deno.exit(1); + exitCode = 1; }); completionsCmd.command("fish", "Generate fish completion script.") .action(() => { console.error("completions fish: not yet implemented (issue #10)"); - Deno.exit(1); + exitCode = 1; }); return cli as unknown as Command; diff --git a/src/compose/sync.ts b/src/compose/sync.ts new file mode 100644 index 0000000..d08ab5d --- /dev/null +++ b/src/compose/sync.ts @@ -0,0 +1,154 @@ +/** + * Stack sync pipeline - diff-only validation. + * + * Orchestrates: config -> discover -> generate into temp -> diff against canonical stacks. + * Does NOT render and MUST NEVER deploy. + */ +import { resolveConfig } from "../config/load.ts"; +import { discoverComposeFiles } from "./discover.ts"; +import { generateStacks } from "./generate.ts"; +import { join } from "@std/path"; +import { exists } from "@std/fs"; +import type { ResolvedConfig } from "../config/types.ts"; + +export interface SyncOptions { + stacks?: string[]; + config?: string; + profile?: string; + quiet?: boolean; + nonInteractive?: boolean; +} + +export interface SyncResult { + match: boolean; + diffs: Record; + errors: string[]; + warnings: string[]; +} + +export async function sync(opts: SyncOptions): Promise { + const result: SyncResult = { match: true, diffs: {}, errors: [], warnings: [] }; + + let config: ResolvedConfig; + try { + config = await resolveConfig({ configPath: opts.config, profile: opts.profile }); + } catch (err: unknown) { + result.errors.push( + `Config resolution failed: ${err instanceof Error ? err.message : String(err)}`, + ); + result.match = false; + return result; + } + + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + const stacksDir = join(repoRoot, config.base.stack.directory); + + const discovery = await discoverComposeFiles({ repoRoot }); + const targetStacks = opts.stacks ?? Object.keys(discovery.stacks); + + if (targetStacks.length === 0) { + result.warnings.push("No stacks discovered"); + return result; + } + + const genResult = await generateStacks({ + stacks: targetStacks, + repoRoot, + outputDir: undefined, + dryRun: true, + }); + + for (const w of genResult.warnings) result.warnings.push(w); + for (const e of genResult.errors) result.errors.push(e); + + for (const [stackName, generatedContent] of Object.entries(genResult.generated)) { + const canonicalPath = join(stacksDir, `${stackName}.yml`); + let canonicalContent = ""; + try { + if (await exists(canonicalPath)) { + canonicalContent = await Deno.readTextFile(canonicalPath); + } + } catch (err: unknown) { + result.errors.push( + `Failed to read canonical stack: ${err instanceof Error ? err.message : String(err)}`, + ); + result.match = false; + continue; + } + if (generatedContent !== canonicalContent) { + result.match = false; + result.diffs[stackName] = generateDiff(canonicalPath, canonicalContent, generatedContent); + } else { + result.diffs[stackName] = ""; + } + } + + return result; +} + +function generateDiff(canonicalPath: string, canonical: string, generated: string): string { + const aLines = canonical.split("\n"); + const bLines = generated.split("\n"); + if (aLines.length && aLines[aLines.length - 1] === "") aLines.pop(); + if (bLines.length && bLines[bLines.length - 1] === "") bLines.pop(); + const diffLines = [`--- ${canonicalPath}`, "+++ "]; + const lcs = lcsFn(aLines, bLines); + let ai = 0, bi = 0, li = 0; + while (ai < aLines.length || bi < bLines.length) { + if (li < lcs.length) { + const common = lcs[li]; + let skipA = 0, skipB = 0; + while (ai < aLines.length && aLines[ai] !== common) { + ai++; + skipA++; + } + while (bi < bLines.length && bLines[bi] !== common) { + bi++; + skipB++; + } + const startAi = ai - skipA, startBi = bi - skipB; + for (let i = 0; i < Math.max(skipA, skipB); i++) { + if (i < skipA && i < skipB) { + diffLines.push(`- ${aLines[startAi + i]}`, `+ ${bLines[startBi + i]}`); + } else if (i < skipA) diffLines.push(`- ${aLines[startAi + i]}`); + else diffLines.push(`+ ${bLines[startBi + i]}`); + } + if (ai < aLines.length) { + diffLines.push(` ${aLines[ai]}`); + ai++; + bi++; + li++; + } + } else { + while (bi < bLines.length) { + diffLines.push(`+ ${bLines[bi]}`); + bi++; + } + break; + } + } + return diffLines.join("\n"); +} + +function lcsFn(a: T[], b: T[]): T[] { + const m = a.length, n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + const result: T[] = []; + let i = m, j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + result.unshift(a[i - 1]); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) i--; + else j--; + } + return result; +} diff --git a/src/compose/sync_test.ts b/src/compose/sync_test.ts new file mode 100644 index 0000000..50312b6 --- /dev/null +++ b/src/compose/sync_test.ts @@ -0,0 +1,173 @@ +/** + * Tests for the stack sync pipeline (diff-only validation). + */ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { sync } from "./sync.ts"; +import { generateStacks } from "./generate.ts"; + +async function setupConfigDir(dir: string, projectName = "test-project"): Promise { + const config = [ + `project: ${projectName}`, + "stack:", + " directory: stacks", + " names:", + " - platform", + " network: traefik-public", + "render:", + " outputDirectory: .rendered", + "env:", + " activeName: .env", + ].join("\n"); + await Deno.writeTextFile(`${dir}/.stackctl`, config); +} + +async function setupService(dir: string, stackName: string, serviceName: string): Promise { + const svcDir = `${dir}/services/${serviceName}`; + await Deno.mkdir(svcDir, { recursive: true }); + const compose = [ + `x-stack: ${stackName}`, + "services:", + ` ${serviceName}:`, + " image: nginx:alpine", + " ports:", + ' - "8080:80"', + " deploy:", + " replicas: 1", + ].join("\n"); + await Deno.writeTextFile(`${svcDir}/docker-compose.yml`, compose); +} + +async function setupCanonicalStack(dir: string, stackName: string, content: string): Promise { + const stacksDir = `${dir}/stacks`; + await Deno.mkdir(stacksDir, { recursive: true }); + await Deno.writeTextFile(`${stacksDir}/${stackName}.yml`, content); +} + +Deno.test("sync: fails gracefully when no config found", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.errors.length, 1); + assertStringIncludes(result.errors[0], "Config"); + assertEquals(result.match, false); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: resolves config successfully with no stacks", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.errors.length, 0); + assertEquals(result.match, true); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: detects match when canonical matches generated content", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + await setupService(tmp, "platform", "web"); + + // Generate the stack and use its output as the canonical file + const genResult = await generateStacks({ + stacks: ["platform"], + repoRoot: tmp, + outputDir: undefined, + dryRun: true, + }); + const generatedContent = genResult.generated["platform"]; + await setupCanonicalStack(tmp, "platform", generatedContent); + + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.errors.length, 0); + assertEquals(result.match, true); + assertEquals(result.diffs["platform"], ""); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: detects drift when stacks differ", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + await setupService(tmp, "platform", "web"); + await setupCanonicalStack(tmp, "platform", "# old content\nservices:\n old: {}\n"); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.errors.length, 0); + assertEquals(result.match, false); + assert(result.diffs["platform"].length > 0); + assertStringIncludes(result.diffs["platform"], "---"); + assertStringIncludes(result.diffs["platform"], "+++"); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: detects missing canonical file as drift", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + await setupService(tmp, "platform", "web"); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.errors.length, 0); + assertEquals(result.match, false); + assert(result.diffs["platform"].length > 0); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: quiet mode records diffs in result", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + await setupService(tmp, "platform", "web"); + await setupCanonicalStack(tmp, "platform", "# old\nservices: {}\n"); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({ quiet: true }); + assertEquals(result.match, false); + assert(result.diffs["platform"].length > 0); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); + +Deno.test("sync: handles repo with no stacks gracefully", async () => { + const tmp = await Deno.makeTempDir({ prefix: "stackctl-sync-test-" }); + await setupConfigDir(tmp); + const origCwd = Deno.cwd; + try { + Deno.cwd = () => tmp; + const result = await sync({}); + assertEquals(result.warnings.length, 1); + assertStringIncludes(result.warnings[0], "No stacks discovered"); + assertEquals(Object.keys(result.diffs).length, 0); + assertEquals(result.match, true); + } finally { + Deno.cwd = origCwd; + await Deno.remove(tmp, { recursive: true }); + } +}); diff --git a/src/docker/docker_test.ts b/src/docker/docker_test.ts new file mode 100644 index 0000000..eb38225 --- /dev/null +++ b/src/docker/docker_test.ts @@ -0,0 +1,369 @@ +/** + * Tests for the Docker CLI integration module. + * + * Uses FakeProcessRunner — never talks to real Docker. + */ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { FakeProcessRunner, FakeProcessRunnerBuilder, successResult } from "../testing/fakes.ts"; +import { + dockerComposeConfig, + dockerInfo, + dockerServiceLogs, + dockerStackDeploy, + dockerStackPs, + dockerStackRm, + dockerStackServices, + dockerSwarmStatus, +} from "./mod.ts"; + +// --------------------------------------------------------------------------- +// dockerStackDeploy +// --------------------------------------------------------------------------- + +Deno.test("dockerStackDeploy: builds correct command (minimal)", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "deploy", "--compose-file", "/tmp/test.yml", "mystack"], + { stdout: "Deploying...", code: 0 }, + ).build(); + + const result = await dockerStackDeploy(runner, "mystack", "/tmp/test.yml"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "Deploying"); + assertEquals(runner.containsCommand(["docker", "stack", "deploy"]), true); +}); + +Deno.test("dockerStackDeploy: includes prune flag", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "deploy", "--compose-file", "/tmp/test.yml", "--prune", "mystack"], + { code: 0 }, + ).build(); + + const result = await dockerStackDeploy(runner, "mystack", "/tmp/test.yml", { prune: true }); + + assertEquals(result.code, 0); +}); + +Deno.test("dockerStackDeploy: includes detach flag", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "deploy", "--compose-file", "/tmp/test.yml", "--detach", "mystack"], + { code: 0 }, + ).build(); + + const result = await dockerStackDeploy(runner, "mystack", "/tmp/test.yml", { detach: true }); + + assertEquals(result.code, 0); +}); + +Deno.test("dockerStackDeploy: includes resolve-image flag", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + [ + "docker", + "stack", + "deploy", + "--compose-file", + "/tmp/test.yml", + "--resolve-image", + "always", + "mystack", + ], + { code: 0 }, + ).build(); + + const result = await dockerStackDeploy(runner, "mystack", "/tmp/test.yml", { + resolveImage: "always", + }); + + assertEquals(result.code, 0); +}); + +Deno.test("dockerStackDeploy: handles deploy failure", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "deploy", "--compose-file", "/tmp/bad.yml", "badstack"], + { stderr: "not a Swarm manager", code: 1 }, + ).build(); + + const result = await dockerStackDeploy(runner, "badstack", "/tmp/bad.yml"); + + assertEquals(result.code, 1); + assert(!result.success); + assertStringIncludes(result.stderr, "not a Swarm manager"); +}); + +// --------------------------------------------------------------------------- +// dockerStackRm +// --------------------------------------------------------------------------- + +Deno.test("dockerStackRm: builds correct command", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "rm", "mystack"], + { stdout: "Removing service...", code: 0 }, + ).build(); + + const result = await dockerStackRm(runner, "mystack"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "Removing"); +}); + +Deno.test("dockerStackRm: handles removal failure", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "rm", "nonexistent"], + { stderr: "nothing found in stack", code: 1 }, + ).build(); + + const result = await dockerStackRm(runner, "nonexistent"); + + assertEquals(result.code, 1); + assert(!result.success); +}); + +// --------------------------------------------------------------------------- +// dockerStackServices +// --------------------------------------------------------------------------- + +Deno.test("dockerStackServices: uses JSON format for machine parsing", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "services", "--format", "{{json .}}", "mystack"], + { stdout: '{"Name":"mystack_web"}\n{"Name":"mystack_db"}', code: 0 }, + ).build(); + + const result = await dockerStackServices(runner, "mystack"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "mystack_web"); + assertStringIncludes(result.stdout, "mystack_db"); +}); + +Deno.test("dockerStackServices: handles empty stack", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "services", "--format", "{{json .}}", "emptystack"], + { stdout: "", code: 0 }, + ).build(); + + const result = await dockerStackServices(runner, "emptystack"); + + assertEquals(result.code, 0); + assertEquals(result.stdout, ""); +}); + +// --------------------------------------------------------------------------- +// dockerStackPs +// --------------------------------------------------------------------------- + +Deno.test("dockerStackPs: uses JSON format for machine parsing", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "stack", "ps", "--format", "{{json .}}", "mystack"], + { stdout: '{"Name":"mystack_web.1","DesiredState":"Running"}', code: 0 }, + ).build(); + + const result = await dockerStackPs(runner, "mystack"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "Running"); +}); + +// --------------------------------------------------------------------------- +// dockerServiceLogs +// --------------------------------------------------------------------------- + +Deno.test("dockerServiceLogs: builds correct command with defaults", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "service", "logs", "--follow", "myservice"], + { stdout: "log line 1\nlog line 2", code: 0 }, + ).build(); + + const result = await dockerServiceLogs(runner, "myservice"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "log line 1"); +}); + +Deno.test("dockerServiceLogs: includes tail option", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "service", "logs", "--follow", "--tail", "50", "myservice"], + { stdout: "recent log", code: 0 }, + ).build(); + + const result = await dockerServiceLogs(runner, "myservice", { tail: 50 }); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "recent log"); +}); + +Deno.test("dockerServiceLogs: can disable follow", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "service", "logs", "--tail", "100", "myservice"], + { stdout: "all logs", code: 0 }, + ).build(); + + const result = await dockerServiceLogs(runner, "myservice", { follow: false, tail: 100 }); + + assertEquals(result.code, 0); +}); + +Deno.test("dockerServiceLogs: includes since and timestamps", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "service", "logs", "--follow", "--since", "2024-01-01", "--timestamps", "myservice"], + { stdout: "timestamped log", code: 0 }, + ).build(); + + const result = await dockerServiceLogs(runner, "myservice", { + since: "2024-01-01", + timestamps: true, + }); + + assertEquals(result.code, 0); +}); + +// --------------------------------------------------------------------------- +// dockerInfo +// --------------------------------------------------------------------------- + +Deno.test("dockerInfo: returns JSON formatted info", async () => { + const infoJson = JSON.stringify({ Swarm: { LocalNodeState: "active", NodeID: "abc123" } }); + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stdout: infoJson, code: 0 }, + ).build(); + + const result = await dockerInfo(runner); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "active"); +}); + +Deno.test("dockerInfo: handles docker not running", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stderr: "Cannot connect to the Docker daemon", code: 1 }, + ).build(); + + const result = await dockerInfo(runner); + + assertEquals(result.code, 1); + assert(!result.success); +}); + +// --------------------------------------------------------------------------- +// dockerSwarmStatus +// --------------------------------------------------------------------------- + +Deno.test("dockerSwarmStatus: detects active Swarm mode", async () => { + const infoJson = JSON.stringify({ Swarm: { LocalNodeState: "active", NodeID: "node123" } }); + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stdout: infoJson, code: 0 }, + ).build(); + + const status = await dockerSwarmStatus(runner); + + assertEquals(status.active, true); + assertEquals(status.nodeId, "node123"); +}); + +Deno.test("dockerSwarmStatus: detects inactive Swarm", async () => { + const infoJson = JSON.stringify({ Swarm: { LocalNodeState: "inactive" } }); + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stdout: infoJson, code: 0 }, + ).build(); + + const status = await dockerSwarmStatus(runner); + + assertEquals(status.active, false); +}); + +Deno.test("dockerSwarmStatus: handles missing Swarm key", async () => { + const infoJson = JSON.stringify({ Containers: 5 }); + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stdout: infoJson, code: 0 }, + ).build(); + + const status = await dockerSwarmStatus(runner); + + assertEquals(status.active, false); +}); + +Deno.test("dockerSwarmStatus: handles bad JSON gracefully", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stdout: "not valid json{{{", code: 0 }, + ).build(); + + const status = await dockerSwarmStatus(runner); + + assertEquals(status.active, false); +}); + +Deno.test("dockerSwarmStatus: returns inactive when docker info fails", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "info", "--format", "{{json .}}"], + { stderr: "docker: command not found", code: 127 }, + ).build(); + + const status = await dockerSwarmStatus(runner); + + assertEquals(status.active, false); +}); + +// --------------------------------------------------------------------------- +// Dry-run mode propagation +// --------------------------------------------------------------------------- + +Deno.test("docker commands respect dryRun mode", async () => { + // In dry-run mode, FakeProcessRunner with dryRun=true still returns + // the configured result — the real process runner would skip execution. + const runner = new FakeProcessRunner([{ + match: ["docker", "stack", "services"], + result: successResult('{"Name":"svc"}'), + }], true); // dryRun = true + + const result = await dockerStackServices(runner, "mystack"); + + assertEquals(result.code, 0); + assertEquals(runner.dryRun, true); +}); + +// --------------------------------------------------------------------------- +// dockerComposeConfig +// --------------------------------------------------------------------------- + +Deno.test("dockerComposeConfig: runs docker compose config with -f flag", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "compose", "-f", "/tmp/test.yml", "config"], + { stdout: "services:\n web:\n image: nginx", code: 0 }, + ).build(); + + const result = await dockerComposeConfig(runner, "/tmp/test.yml"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "web"); + assert(result.success); +}); + +Deno.test("dockerComposeConfig: reports invalid compose files", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "compose", "-f", "/tmp/bad.yml", "config"], + { stderr: "services.web Additional property bogus is not allowed", code: 1 }, + ).build(); + + const result = await dockerComposeConfig(runner, "/tmp/bad.yml"); + + assertEquals(result.code, 1); + assert(!result.success); + assertStringIncludes(result.stderr, "bogus"); +}); + +Deno.test("dockerComposeConfig: handles missing file gracefully", async () => { + const runner = FakeProcessRunnerBuilder.forCommand( + ["docker", "compose", "-f", "/tmp/missing.yml", "config"], + { stderr: "stat /tmp/missing.yml: no such file or directory", code: 14 }, + ).build(); + + const result = await dockerComposeConfig(runner, "/tmp/missing.yml"); + + assertEquals(result.code, 14); + assert(!result.success); +}); diff --git a/src/docker/mod.ts b/src/docker/mod.ts new file mode 100644 index 0000000..63ab0b9 --- /dev/null +++ b/src/docker/mod.ts @@ -0,0 +1,91 @@ +/** + * Docker CLI integration module. + * + * All Docker commands go through ProcessRunner for testability. + */ +import type { ProcessResult, ProcessRunner } from "../process/types.ts"; + +export interface DockerDeployOptions { + prune?: boolean; + detach?: boolean; + resolveImage?: string; +} + +export interface DockerLogsOptions { + follow?: boolean; + tail?: number; + since?: string; + timestamps?: boolean; +} + +export function dockerStackDeploy( + runner: ProcessRunner, + stackName: string, + composeFile: string, + opts?: DockerDeployOptions, +): Promise { + const cmd = ["docker", "stack", "deploy"]; + cmd.push("--compose-file", composeFile); + if (opts?.prune) cmd.push("--prune"); + if (opts?.detach) cmd.push("--detach"); + if (opts?.resolveImage) cmd.push("--resolve-image", opts.resolveImage); + cmd.push(stackName); + return runner.run(cmd); +} + +export function dockerStackRm(runner: ProcessRunner, stackName: string): Promise { + return runner.run(["docker", "stack", "rm", stackName]); +} + +export function dockerStackServices( + runner: ProcessRunner, + stackName: string, +): Promise { + return runner.run(["docker", "stack", "services", "--format", "{{json .}}", stackName]); +} + +export function dockerStackPs(runner: ProcessRunner, stackName: string): Promise { + return runner.run(["docker", "stack", "ps", "--format", "{{json .}}", stackName]); +} + +export function dockerServiceLogs( + runner: ProcessRunner, + serviceName: string, + opts?: DockerLogsOptions, +): Promise { + const cmd = ["docker", "service", "logs"]; + if (opts?.follow !== false) cmd.push("--follow"); + if (opts?.tail !== undefined) cmd.push("--tail", String(opts.tail)); + if (opts?.since) cmd.push("--since", opts.since); + if (opts?.timestamps) cmd.push("--timestamps"); + cmd.push(serviceName); + return runner.stream(cmd); +} + +export function dockerInfo(runner: ProcessRunner): Promise { + return runner.run(["docker", "info", "--format", "{{json .}}"]); +} + +export async function dockerSwarmStatus( + runner: ProcessRunner, +): Promise<{ active: boolean; nodeId?: string }> { + const result = await runner.run(["docker", "info", "--format", "{{json .}}"]); + if (!result.success) return { active: false }; + try { + const info = JSON.parse(result.stdout) as Record; + const swarm = info?.Swarm as Record | undefined; + if (swarm?.LocalNodeState === "active") { + return { active: true, nodeId: swarm.NodeID as string | undefined }; + } + return { active: false }; + } catch { + return { active: false }; + } +} + +export function dockerComposeConfig( + runner: ProcessRunner, + composeFile: string, +): Promise { + return runner.run(["docker", "compose", "-f", composeFile, "config"]); +} diff --git a/src/process/runner.ts b/src/process/runner.ts new file mode 100644 index 0000000..365576e --- /dev/null +++ b/src/process/runner.ts @@ -0,0 +1,194 @@ +/** + * Real ProcessRunner implementation using Deno.Command. + * + * All external commands go through this interface. + * This enables dry-run, test faking, signal forwarding, and permission validation. + */ +import type { ProcessResult, ProcessRunner, RunOptions, StreamOptions } from "./types.ts"; + +/** + * Real process runner that executes commands via Deno.Command. + * + * Two modes: + * - Normal: executes commands against the real OS + * - Dry-run: logs the intended command instead of executing + */ +export class RealProcessRunner implements ProcessRunner { + readonly dryRun: boolean; + + constructor(dryRun = false) { + this.dryRun = dryRun; + } + + /** Run a command and capture its output. */ + async run(cmd: string[], options?: RunOptions): Promise { + if (cmd.length === 0) { + return { stdout: "", stderr: "", code: 1, success: false, command: cmd }; + } + + if (this.dryRun) { + const msg = `[dry-run] would run: ${cmd.join(" ")}`; + console.log(msg); + return { stdout: "", stderr: "", code: 0, success: true, command: cmd }; + } + + const [executable, ...args] = cmd; + const command = new Deno.Command(executable, { + args, + stdout: "piped", + stderr: "piped", + cwd: options?.cwd, + env: options?.env, + }); + + let output: Deno.CommandOutput; + try { + output = await command.output(); + } catch (err: unknown) { + return { + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + code: 1, + success: false, + command: cmd, + }; + } + + const decoder = new TextDecoder(); + return { + stdout: decoder.decode(output.stdout), + stderr: decoder.decode(output.stderr), + code: output.code, + success: output.success, + command: cmd, + }; + } + + /** Run a command with streaming output via onStdout/onStderr callbacks. */ + async stream(cmd: string[], options?: StreamOptions): Promise { + if (cmd.length === 0) { + return { stdout: "", stderr: "", code: 1, success: false, command: cmd }; + } + + if (this.dryRun) { + const msg = `[dry-run] would stream: ${cmd.join(" ")}`; + console.log(msg); + return { stdout: "", stderr: "", code: 0, success: true, command: cmd }; + } + + const [executable, ...args] = cmd; + const command = new Deno.Command(executable, { + args, + stdout: "piped", + stderr: "piped", + cwd: options?.cwd, + env: options?.env, + }); + + const child = command.spawn(); + + // Forward SIGINT/SIGTERM to child process + const signalHandler = () => { + try { + child.kill("SIGTERM"); + } catch { + // Child may already have exited + } + }; + Deno.addSignalListener("SIGINT", signalHandler); + Deno.addSignalListener("SIGTERM", signalHandler); + + try { + const stdoutText = await drainStream(child.stdout, options?.onStdout); + const stderrText = await drainStream(child.stderr, options?.onStderr); + const status = await child.status; + + return { + stdout: stdoutText, + stderr: stderrText, + code: status.code, + success: status.success, + command: cmd, + }; + } catch (err: unknown) { + return { + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + code: 1, + success: false, + command: cmd, + }; + } finally { + Deno.removeSignalListener("SIGINT", signalHandler); + Deno.removeSignalListener("SIGTERM", signalHandler); + } + } + + /** Validate that a command binary exists on PATH. */ + async which(name: string): Promise { + try { + const command = new Deno.Command("which", { args: [name], stdout: "null", stderr: "null" }); + const output = await command.output(); + return output.success; + } catch { + return false; + } + } + + /** Create a new runner with the given dry-run mode. */ + withDryRun(dryRun: boolean): ProcessRunner { + return new RealProcessRunner(dryRun); + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Drain a ReadableStream to a string, optionally emitting lines + * through an onLine callback. + */ +async function drainStream( + stream: ReadableStream, + onLine?: (line: string) => void, +): Promise { + const decoder = new TextDecoder(); + let result = ""; + let buffer = ""; + + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + if (onLine) { + // Emit complete lines, keeping residual in buffer + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + onLine(line); + } + } + + result += chunk; + } + } finally { + try { + reader.releaseLock(); + } catch { + // Reader may already be closed + } + } + + // Flush remaining buffer + if (onLine && buffer) { + onLine(buffer); + } + + return result; +}