diff --git a/src/cli/mod.ts b/src/cli/mod.ts index e3c495f..058f749 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -33,6 +33,9 @@ import { import type { EnvDiff } from "../env/types.ts"; import { reloadStacks } from "../compose/reload.ts"; import type { ReloadResult } from "../compose/reload.ts"; +import { planOperation } from "../compose/plan.ts"; +import type { PlanResult } from "../compose/plan.ts"; +import { CompletionsCommand } from "@cliffy/command/completions"; import { checkTooling, cleanDecryptedEnvFiles, @@ -62,10 +65,24 @@ export async function main(args: string[]): Promise { } } +/** + * Best-effort stack-name completion provider. + * Returns stack names discovered from the repository. + * Never throws — returns an empty array if config or discovery fails. + */ +async function completeStackNames(): Promise { + try { + const config = await resolveConfig({ profile: undefined, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + const discovery = await discoverComposeFiles({ repoRoot }); + return Object.keys(discovery.stacks); + } catch { + return []; + } +} + /** * Build the stackctl CLI command tree. - * Commands are registered here in their skeleton form; - * full implementations are added in subsequent issues. */ export function buildCli(): Command { const cli = new Command() @@ -1470,31 +1487,98 @@ export function buildCli(): Command { cli.command("plan", "Produce a deterministic plan of what an operation would do.") .arguments("") .option("--profile ", "Use a specific profile.") - .option("--stacks ", "Comma-separated list of stack names.") + .option("--stacks ", "Comma-separated list of stack names.", { + complete: completeStackNames, + } as any) .option("--override ", "Comma-separated list of override files.") .option("--json", "Output machine-readable JSON.") - .action(() => { - console.error("plan: not yet implemented (issue #15)"); - exitCode = 1; + .description( + "Shows a structured summary of what the specified operation would do without executing it.\n\n" + + "Supported operations:\n" + + " up - Preview stack deployment\n" + + " down - Preview stack removal\n" + + " sync - Preview full generate+render+deploy pipeline\n" + + " generate - Preview stack generation only\n" + + " render - Preview rendering only\n" + + " reload - Preview config-first reload\n" + + " env - Preview env file scaffolding\n" + + " secrets - Preview secrets workflow\n" + + " all - Preview everything", + ) + .example( + "Preview what would happen during a sync", + "stackctl plan sync", + ) + .example( + "Preview with a specific profile", + "stackctl plan up --profile staging", + ) + .example( + "Preview specific stacks only", + "stackctl plan generate --stacks api,web", + ) + .example( + "Machine-readable JSON output", + "stackctl plan all --json", + ) + .action((opts: Record, operation: string) => { + const profile = opts.profile as string | undefined; + const stacks = opts.stacks + ? (opts.stacks as string).split(",").map((s: string) => s.trim()) + : undefined; + const overrides = opts.override + ? (opts.override as string).split(",").map((s: string) => s.trim()) + : undefined; + + planOperation({ + operation, + profile, + stacks, + overrides, + }) + .then((plan: PlanResult) => { + if (opts.json) { + console.log(JSON.stringify(plan.json, null, 2)); + return; + } + + // Human-readable output + console.log(`Plan: ${plan.operation}`); + console.log("=".repeat(40)); + + for (const section of plan.sections) { + console.log(`\n${section.title}`); + console.log("-".repeat(section.title.length)); + for (const item of section.items) { + console.log(item); + } + } + + if (plan.warnings.length > 0) { + console.log("\nWarnings:"); + for (const w of plan.warnings) { + console.log(` ! ${w}`); + } + } + + if (plan.errors.length > 0) { + console.log("\nErrors:"); + for (const e of plan.errors) { + console.log(` ✗ ${e}`); + } + Deno.exit(ExitCode.DriftOrValidation); + } + }) + .catch((err: unknown) => { + console.error( + `error: ${err instanceof Error ? err.message : String(err)}`, + ); + Deno.exit(ExitCode.UnexpectedError); + }); }); // --- completions (issue #10) --- - const completionsCmd = cli.command("completions", "Generate shell completion scripts."); - completionsCmd.command("bash", "Generate bash completion script.") - .action(() => { - console.error("completions bash: not yet implemented (issue #10)"); - exitCode = 1; - }); - completionsCmd.command("zsh", "Generate zsh completion script.") - .action(() => { - console.error("completions zsh: not yet implemented (issue #10)"); - exitCode = 1; - }); - completionsCmd.command("fish", "Generate fish completion script.") - .action(() => { - console.error("completions fish: not yet implemented (issue #10)"); - exitCode = 1; - }); + cli.command("completions", new CompletionsCommand()); return cli as unknown as Command; } diff --git a/src/compose/plan.ts b/src/compose/plan.ts new file mode 100644 index 0000000..c3a8301 --- /dev/null +++ b/src/compose/plan.ts @@ -0,0 +1,720 @@ +/** + * Plan module — deterministic operation preview. + * + * Produces a structured summary of what a given operation would do + * without executing any mutation. Supports human-readable output + * and machine-readable JSON with a stable shape. + * + * SAFETY: This module MUST NEVER mutate files, decrypt secrets, or run + * Docker mutating commands. All generation runs with dryRun=true in-memory. + * All secrets operations only discover/locate files without decryption. + */ +import { resolveConfig } from "../config/load.ts"; +import { discoverComposeFiles } from "./discover.ts"; +import { generateStacks } from "./generate.ts"; +import type { ResolvedConfig } from "../config/types.ts"; +import type { OverrideEntry } from "../config/types.ts"; +import type { ComposeData } from "./types.ts"; +import { renderStack } from "../render/mod.ts"; +import { parse as parseYaml } from "@std/yaml"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PlanOptions { + /** Operation to plan: up, down, sync, generate, render, reload, env, secrets, secrets deploy, all */ + operation: string; + /** Active profile name. */ + profile?: string; + /** Stack names to scope. */ + stacks?: string[]; + /** Override file paths. */ + overrides?: string[]; + /** Explicit config file path. */ + config?: string; +} + +export interface PlanSection { + title: string; + items: string[]; + detail?: Record; +} + +/** + * Stable JSON output shape for --json mode. + * All consumers can rely on these fields being present. + */ +export interface PlanJsonOutput { + operation: string; + + /** Resolved config layers with paths. */ + config: { + /** Absolute path to the base .stackctl config file. */ + baseConfig: string; + /** Active profile name, if selected. */ + profile?: string; + /** Absolute path to the selected profile overlay file (.stackctl.). */ + profileConfig?: string; + /** Absolute path to the local override file (.stackctl.local). */ + localConfig?: string; + /** Override files (explicit or profile-discovered) in application order. */ + overrides: string[]; + }; + + /** Stacks that would be affected, with their status. */ + stacks: { name: string; status: string }[]; + + /** Ordered list of steps the operation would perform. */ + steps: { type: string; description: string; command?: string[] }[]; + + /** Non-fatal warnings. */ + warnings: string[]; + + /** For secrets deploy: encrypted input files that would be decrypted. */ + encryptedInputs?: string[]; + + /** For secrets deploy/clean: cleanup actions that would be scheduled. */ + cleanupActions?: string[]; +} + +export interface PlanResult { + operation: string; + sections: PlanSection[]; + dockerCommands: string[]; + errors: string[]; + warnings: string[]; + /** Stable JSON output for --json mode. */ + json: PlanJsonOutput; +} + +// --------------------------------------------------------------------------- +// Config section — reports resolved config layers +// --------------------------------------------------------------------------- + +function planConfig(config: ResolvedConfig): PlanSection { + const items: string[] = []; + + if (config.baseConfigPath) { + items.push(`Base config: ${config.baseConfigPath}`); + } else { + items.push(`Base config: (defaults only, no .stackctl found)`); + } + + if (config.profileConfigPath) { + items.push(`Profile overlay: ${config.profileConfigPath}`); + } + + if (config.localConfigPath) { + items.push(`Local override: ${config.localConfigPath}`); + } + + if (config.profile) { + items.push(`Active profile: ${config.profile}`); + } + + if (config.base.project) { + items.push(`Project: ${config.base.project}`); + } + + if (config.base.stack) { + items.push(`Stack directory: ${config.base.stack.directory}`); + items.push( + `Stack names (config): ${config.base.stack.names.join(", ") || "(none, will auto-detect)"}`, + ); + if (config.base.stack.network) { + items.push(`Default network: ${config.base.stack.network}`); + } + } + + if (config.overrides && config.overrides.length > 0) { + items.push(`Override files: ${config.overrides.length}`); + for (const o of config.overrides) { + items.push(` [${o.source}] ${o.path}`); + } + } + + return { title: "Configuration", items }; +} + +// --------------------------------------------------------------------------- +// Compose discovery section +// --------------------------------------------------------------------------- + +async function planComposeDiscovery( + repoRoot: string, + targetStacks?: string[], +): Promise { + const items: string[] = []; + const detail: Record = {}; + + const discovery = await discoverComposeFiles({ repoRoot }); + const stacks = targetStacks ?? Object.keys(discovery.stacks); + + items.push(`Repository root: ${repoRoot}`); + items.push(`Stacks discovered: ${Object.keys(discovery.stacks).length}`); + + if (stacks.length === 0) { + items.push(" (no stacks found)"); + return { title: "Compose Discovery", items, detail }; + } + + for (const stackName of stacks) { + const files = discovery.stacks[stackName]; + if (!files || files.length === 0) { + items.push(` ${stackName}: (no files found)`); + continue; + } + items.push(` ${stackName}:`); + for (const f of files) { + items.push(` - ${f}`); + } + } + + detail.stacks = stacks; + detail.discovery = discovery; + + return { title: "Compose Discovery", items, detail }; +} + +// --------------------------------------------------------------------------- +// Override section +// --------------------------------------------------------------------------- + +function planOverrides( + overrides?: string[], +): PlanSection { + const items: string[] = []; + + if (!overrides || overrides.length === 0) { + items.push("No explicit overrides specified."); + return { title: "Overrides", items }; + } + + items.push(`Explicit override files: ${overrides.length}`); + for (const o of overrides) { + items.push(` - ${o}`); + } + + return { title: "Overrides", items }; +} + +// --------------------------------------------------------------------------- +// Generation section +// --------------------------------------------------------------------------- + +async function planGeneration( + repoRoot: string, + targetStacks: string[], + overrideEntries: (OverrideEntry | string)[], +): Promise { + const items: string[] = []; + const detail: Record = {}; + + // SAFETY: dryRun=true ensures no files are written + const genResult = await generateStacks({ + stacks: targetStacks, + repoRoot, + outputDir: undefined, + dryRun: true, + overrides: overrideEntries, + }); + + items.push( + `Stacks that would be generated: ${Object.keys(genResult.generated).length}`, + ); + + for (const [name] of Object.entries(genResult.generated)) { + const genPath = `${repoRoot}/stacks/${name}.yml`; + items.push(` - ${name} -> ${genPath}`); + } + + for (const w of genResult.warnings) { + items.push(` warning: ${w}`); + } + + detail.generated = Object.keys(genResult.generated); + detail.errors = genResult.errors; + + return { title: "Stack Generation", items, detail }; +} + +// --------------------------------------------------------------------------- +// Render section +// --------------------------------------------------------------------------- + +async function planRender( + generated: Record, + repoRoot: string, + targetStacks: string[], + outputDir: string, +): Promise { + const items: string[] = []; + + if (Object.keys(generated).length === 0) { + items.push("No stacks to render."); + return { title: "Rendering", items }; + } + + items.push( + `Stacks that would be rendered: ${Object.keys(generated).length}`, + ); + items.push(`Output directory: ${outputDir}`); + + for (const stackName of targetStacks) { + const yamlContent = generated[stackName]; + if (!yamlContent) { + items.push(` ${stackName}: (no generated content)`); + continue; + } + + try { + const parsed = parseYaml(yamlContent) as ComposeData; + const result = await renderStack({ + data: parsed, + projectDir: repoRoot, + repoRoot, + }); + + const vars: string[] = []; + for (const [, svc] of Object.entries(parsed.services || {})) { + if (svc.environment) { + if (Array.isArray(svc.environment)) { + for (const e of svc.environment) { + if (typeof e === "string" && e.includes("${")) { + vars.push(e.split("=")[0]); + } + } + } else if (typeof svc.environment === "object") { + for ( + const [k, v] of Object.entries( + svc.environment as Record, + ) + ) { + if (typeof v === "string" && v.includes("${")) vars.push(k); + } + } + } + if (svc.env_file) { + const envFiles = Array.isArray(svc.env_file) ? svc.env_file : [svc.env_file]; + for (const ef of envFiles) { + vars.push(`env_file:${ef}`); + } + } + } + + const renderedPath = `${repoRoot}/${outputDir}/${stackName}.rendered.yml`; + if (vars.length > 0) { + items.push( + ` ${stackName} -> ${renderedPath} (${vars.length} variable sources)`, + ); + } else { + items.push( + ` ${stackName} -> ${renderedPath} (no variables to interpolate)`, + ); + } + + for (const w of result.warnings) { + items.push(` warning: ${w}`); + } + } catch { + items.push(` ${stackName}: (render skipped — generation error)`); + } + } + + return { title: "Rendering", items }; +} + +// --------------------------------------------------------------------------- +// Docker commands section +// --------------------------------------------------------------------------- + +function planDockerCommands( + operation: string, + targetStacks: string[], +): PlanSection { + const items: string[] = []; + const commands: string[] = []; + + if (operation === "up" || operation === "sync" || operation === "all") { + for (const stack of targetStacks) { + const cmd = `docker stack deploy --compose-file .rendered/${stack}.rendered.yml ${stack}`; + commands.push(cmd); + items.push(cmd); + } + } + + if (operation === "down") { + for (const stack of targetStacks) { + const cmd = `docker stack rm ${stack}`; + commands.push(cmd); + items.push(cmd); + } + } + + if (operation === "reload") { + for (const stack of targetStacks) { + const cmd = `docker stack deploy --compose-file .rendered/${stack}.rendered.yml ${stack}`; + commands.push(cmd); + items.push(`deploy (if changed): ${cmd}`); + } + } + + if (operation === "all") { + for (const stack of targetStacks) { + const deployCmd = + `docker stack deploy --compose-file .rendered/${stack}.rendered.yml ${stack}`; + commands.push(deployCmd); + items.push(` ${deployCmd}`); + } + } + + if (items.length === 0) { + items.push(`No Docker commands for operation "${operation}".`); + } + + return { + title: "Docker Commands", + items, + detail: { commands }, + }; +} + +// --------------------------------------------------------------------------- +// Env section +// --------------------------------------------------------------------------- + +async function planEnv( + repoRoot: string, +): Promise { + const items: string[] = []; + + try { + const { discoverEnvExamples } = await import("../env/mod.ts"); + const examples = await discoverEnvExamples(repoRoot); + + items.push(`Env example files discovered: ${examples.length}`); + let missing = 0; + for (const ex of examples) { + const status = ex.status === "present" ? "✓" : ex.status === "outdated" ? "~" : "✗"; + items.push( + ` ${status} ${ex.serviceName}: ${ex.envPath || "(no .env)"}`, + ); + if (ex.status !== "present") missing++; + } + + if (missing > 0) { + items.push(`\n${missing} .env file(s) need to be created.`); + items.push(" Run: stackctl env create"); + } + } catch { + items.push("Env module not available."); + } + + return { title: "Environment Files", items }; +} + +// --------------------------------------------------------------------------- +// Secrets section +// --------------------------------------------------------------------------- + +/** + * Plan secrets operations WITHOUT decrypting anything. + * + * For "secrets deploy", this reports which encrypted inputs would be used + * and which cleanup actions would be scheduled, but NEVER actually decrypts. + */ +async function planSecrets( + _config: ResolvedConfig, + operation: string, + repoRoot: string, +): Promise { + const items: string[] = []; + const detail: Record = {}; + + try { + const secretsMod = await import("../secrets/mod.ts"); + const findEncryptedEnvFiles = secretsMod.findEncryptedEnvFiles; + + if ( + operation === "secrets deploy" || operation.startsWith("secrets deploy") + ) { + // SAFETY: We only discover files — never decrypt + const encryptedFiles = findEncryptedEnvFiles ? await findEncryptedEnvFiles(repoRoot) : []; + const decryptedFiles: string[] = []; + + items.push( + `Encrypted input files that would be decrypted: ${encryptedFiles.length}`, + ); + for (const f of encryptedFiles) { + items.push(` - ${f}`); + } + detail.encryptedInputs = encryptedFiles; + + // Show cleanup actions that would be scheduled + const cleanupActions: string[] = []; + for (const encFile of encryptedFiles) { + const baseName = encFile.split("/").pop()!; + const parentDir = encFile.substring(0, encFile.lastIndexOf("/")); + const tempOutput = `${parentDir}/${baseName}.stackctl-tmp`; + cleanupActions.push(`Remove temp file: ${tempOutput}`); + } + if (cleanupActions.length > 0) { + items.push(`\nCleanup actions that would be scheduled:`); + for (const action of cleanupActions) { + items.push(` - ${action}`); + } + } else { + items.push(`No cleanup actions needed.`); + } + detail.cleanupActions = cleanupActions; + + // Report plaintext files that have encrypted counterparts (would be cleaned) + const plaintextWithEnc: string[] = []; + for (const df of decryptedFiles) { + const encPath = df + ".enc"; + try { + const { exists } = await import("@std/fs"); + if (await exists(encPath)) { + plaintextWithEnc.push(df); + } + } catch { + // ignore + } + } + if (plaintextWithEnc.length > 0) { + items.push( + `\nPlaintext files with encrypted counterparts (would be cleaned after deploy):`, + ); + for (const f of plaintextWithEnc) { + items.push(` - ${f}`); + } + } + + return { title: "Secrets (deploy)", items, detail }; + } + + // General secrets info (no decryption) + const encryptedFiles = findEncryptedEnvFiles ? await findEncryptedEnvFiles(repoRoot) : []; + items.push( + `Encrypted files discovered: ${encryptedFiles.length}`, + ); + for (const f of encryptedFiles) { + items.push(` - ${f}`); + } + detail.encryptedFiles = encryptedFiles.length; + } catch { + items.push("Secrets module not available."); + } + + return { title: "Secrets", items, detail }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Produce a deterministic plan for a given stackctl operation. + * + * The plan describes what configuration, compose files, overrides, + * generation, rendering, and Docker commands would be involved + * without performing any mutations (no file writes, no decryption, no Docker). + */ +export async function planOperation( + opts: PlanOptions, +): Promise { + const result: PlanResult = { + operation: opts.operation, + sections: [], + dockerCommands: [], + errors: [], + warnings: [], + json: { + operation: opts.operation, + config: { baseConfig: "(none)", overrides: [] }, + stacks: [], + steps: [], + warnings: [], + }, + }; + + // 1. Resolve config + 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.json.config.baseConfig = "(error)"; + return result; + } + + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + const outputDir = config.base.render?.outputDirectory ?? ".rendered"; + + // Build the JSON config block with resolved layers + result.json.config = { + baseConfig: config.baseConfigPath ?? "(not found)", + profile: config.profile, + profileConfig: config.profileConfigPath, + localConfig: config.localConfigPath, + overrides: (opts.overrides ?? []).map((o) => o), + }; + + // Config section (always included) — human output + result.sections.push(planConfig(config)); + + // 2. Compose discovery + const discoverySection = await planComposeDiscovery(repoRoot, opts.stacks); + result.sections.push(discoverySection); + + // Determine target stacks + const discoveryDetail = discoverySection.detail?.discovery as + | { stacks: Record } + | undefined; + const targetStacks = opts.stacks ?? + Object.keys(discoveryDetail?.stacks || {}); + + // Build JSON stacks array + result.json.stacks = targetStacks.map((name) => { + const files = discoveryDetail?.stacks?.[name]; + const hasFiles = Array.isArray(files) && files.length > 0; + return { + name, + status: hasFiles ? "discovered" : "missing", + }; + }); + + // 3. Overrides + result.sections.push(planOverrides(opts.overrides)); + + // Build override entries for generation + const overrideEntries: (OverrideEntry | string)[] = (opts.overrides ?? []) + .map((o) => ({ + source: "explicit" as const, + path: o, + })); + + // 4. Generation (if applicable) + if ( + ["up", "sync", "generate", "reload", "all"].includes(opts.operation) + ) { + const genSection = await planGeneration( + repoRoot, + targetStacks, + overrideEntries, + ); + result.sections.push(genSection); + + result.json.steps.push({ + type: "generate", + description: `Generate ${targetStacks.length} stack(s) to ${repoRoot}/stacks/`, + }); + } + + // 5. Render (if applicable) + if ( + ["up", "sync", "render", "reload", "all"].includes(opts.operation) + ) { + // SAFETY: dryRun=true ensures no files are written + const genResult = await generateStacks({ + stacks: targetStacks, + repoRoot, + outputDir: undefined, + dryRun: true, + overrides: overrideEntries, + }); + const renderSection = await planRender( + genResult.generated, + repoRoot, + targetStacks, + outputDir, + ); + result.sections.push(renderSection); + + result.json.steps.push({ + type: "render", + description: `Render ${ + Object.keys(genResult.generated).length + } stack(s) to ${repoRoot}/${outputDir}/`, + }); + } + + // 6. Docker commands + const dockerSection = planDockerCommands( + opts.operation, + targetStacks, + ); + result.sections.push(dockerSection); + + // Extract docker commands + const dockerDeets = dockerSection.detail as + | { commands: string[] } + | undefined; + result.dockerCommands = dockerDeets?.commands ?? []; + + if (result.dockerCommands.length > 0) { + result.json.steps.push({ + type: "docker", + description: `Execute ${result.dockerCommands.length} Docker command(s)`, + command: result.dockerCommands, + }); + } + + // 7. Env section (for env and all operations) + if (["env", "all"].includes(opts.operation)) { + const envSection = await planEnv(repoRoot); + result.sections.push(envSection); + + result.json.steps.push({ + type: "env", + description: "Inspect .env examples and status", + }); + } + + // 8. Secrets section (for secrets and all operations) + if ( + opts.operation === "secrets" || + opts.operation.startsWith("secrets ") || + opts.operation === "all" + ) { + const secretsSection = await planSecrets( + config, + opts.operation, + repoRoot, + ); + result.sections.push(secretsSection); + + // Attach encryptedInputs and cleanupActions from secrets section to JSON + if (secretsSection.detail) { + if (Array.isArray(secretsSection.detail.encryptedInputs)) { + result.json.encryptedInputs = secretsSection.detail + .encryptedInputs as string[]; + } + if (Array.isArray(secretsSection.detail.cleanupActions)) { + result.json.cleanupActions = secretsSection.detail + .cleanupActions as string[]; + } + } + + result.json.steps.push({ + type: "secrets", + description: `Secrets operation: ${opts.operation}`, + }); + } + + // Collect warnings into JSON + result.json.warnings = [ + ...result.warnings, + ...result.sections.flatMap((s) => + s.items.filter((i) => i.startsWith(" warning:")).map((i) => i.replace(/^\s*warning:\s*/, "")) + ), + ]; + + return result; +} diff --git a/src/compose/plan_test.ts b/src/compose/plan_test.ts new file mode 100644 index 0000000..d33dd38 --- /dev/null +++ b/src/compose/plan_test.ts @@ -0,0 +1,612 @@ +/** + * Tests for the plan command module. + * + * Verifies: + * - planOperation returns expected sections and stable JSON shape + * - SAFETY: plan never mutates files, decrypts secrets, or runs Docker + * - Resolved config layers appear in JSON output + * - "secrets deploy" shows encryptedInputs/cleanupActions without decrypting + */ +import { assert, assertEquals, assertExists, assertStringIncludes } from "@std/assert"; +import { planOperation } from "./plan.ts"; +import type { PlanJsonOutput } from "./plan.ts"; + +function makeTempDir(): Promise { + return Deno.makeTempDir({ prefix: "stackctl-test-plan-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + const path = `${dir}/${name}`; + const parent = path.substring(0, path.lastIndexOf("/")); + await Deno.mkdir(parent, { recursive: true }); + await Deno.writeTextFile(path, content); +} + +/** + * Creates a minimal fixture with a single service. + */ +async function createMinimalFixture(repoRoot: string) { + await writeFile( + repoRoot, + ".stackctl", + [ + "project: test-project", + "stack:", + " directory: stacks", + " names:", + " - test-stack", + " network: test-net", + "render:", + " outputDirectory: .rendered", + ].join("\n"), + ); + + await writeFile( + repoRoot, + "services/test-app/docker-compose.yml", + [ + "x-stack: test-stack", + "", + "services:", + " app:", + " image: nginx:alpine", + " ports:", + ' - "8080:80"', + ].join("\n"), + ); +} + +/** + * Creates a fixture with two services across two stacks. + */ +async function createMultiStackFixture(repoRoot: string) { + await writeFile( + repoRoot, + ".stackctl", + [ + "project: multi-stack", + "stack:", + " directory: stacks", + " names:", + " - api-stack", + " - web-stack", + " network: demo-net", + "render:", + " outputDirectory: .rendered", + ].join("\n"), + ); + + await writeFile( + repoRoot, + "services/api/docker-compose.yml", + [ + "x-stack: api-stack", + "", + "services:", + " api:", + " image: api:latest", + " ports:", + ' - "4000:4000"', + ].join("\n"), + ); + + await writeFile( + repoRoot, + "services/web/docker-compose.yml", + [ + "x-stack: web-stack", + "", + "services:", + " web:", + " image: web:latest", + " ports:", + ' - "3000:3000"', + ].join("\n"), + ); +} + +/** + * Creates a fixture with a profile overlay (.stackctl.staging). + */ +async function createProfileFixture(repoRoot: string) { + await writeFile( + repoRoot, + ".stackctl", + [ + "project: test-project", + "stack:", + " directory: stacks", + " names:", + " - test-stack", + " network: test-net", + "render:", + " outputDirectory: .rendered", + ].join("\n"), + ); + + await writeFile( + repoRoot, + ".stackctl.staging", + [ + "project: test-project-staging", + "stack:", + " network: staging-net", + ].join("\n"), + ); + + await writeFile( + repoRoot, + "services/test-app/docker-compose.yml", + [ + "x-stack: test-stack", + "", + "services:", + " app:", + " image: nginx:alpine", + " ports:", + ' - "8080:80"', + ].join("\n"), + ); +} + +// --------------------------------------------------------------------------- +// Core structure tests +// --------------------------------------------------------------------------- + +Deno.test("planOperation — returns expected structure for generate operation", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "generate", + }); + + assertEquals(result.operation, "generate"); + assertEquals(result.errors.length, 0); + assert(result.sections.length >= 1); + + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Configuration"); + assertStringIncludes(titles.join(","), "Compose Discovery"); + assertStringIncludes(titles.join(","), "Overrides"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — returns expected structure for up operation", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "up", + }); + + assertEquals(result.errors.length, 0); + assert(result.dockerCommands.length > 0); + for (const cmd of result.dockerCommands) { + assertStringIncludes(cmd, "docker stack deploy"); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — returns expected structure for down operation", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "down", + }); + + assertEquals(result.errors.length, 0); + assert(result.dockerCommands.length > 0); + for (const cmd of result.dockerCommands) { + assertStringIncludes(cmd, "docker stack rm"); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — returns expected structure for sync operation", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "sync", + }); + + assertEquals(result.errors.length, 0); + + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Stack Generation"); + assertStringIncludes(titles.join(","), "Rendering"); + assertStringIncludes(titles.join(","), "Docker Commands"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — filters stacks with stacks option", async () => { + const repoRoot = await makeTempDir(); + await createMultiStackFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "generate", + stacks: ["api-stack"], + }); + + assertEquals(result.errors.length, 0); + + const allItems = result.sections.flatMap((s) => s.items).join("\n"); + assertStringIncludes(allItems, "api-stack"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — profile shows in config section", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "generate", + profile: "staging", + }); + + assertEquals(result.errors.length, 0); + + const configSection = result.sections.find((s) => s.title === "Configuration")!; + assertStringIncludes(configSection.items.join("\n"), "staging"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — error when config is missing", async () => { + const repoRoot = await makeTempDir(); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "generate", + }); + + assertEquals(result.errors.length, 1); + assertStringIncludes(result.errors[0].toLowerCase(), "config"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +// --------------------------------------------------------------------------- +// Stable JSON shape tests +// --------------------------------------------------------------------------- + +Deno.test("planOperation — JSON output has stable shape fields", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "all", + }); + + assertEquals(result.errors.length, 0); + + const json = result.json as PlanJsonOutput; + + // Required fields + assertEquals(typeof json.operation, "string"); + assertEquals(typeof json.config, "object"); + assertEquals(typeof json.config.baseConfig, "string"); + assert(Array.isArray(json.config.overrides)); + assert(Array.isArray(json.stacks)); + assert(Array.isArray(json.steps)); + assert(Array.isArray(json.warnings)); + + // Stacks have name and status + for (const stack of json.stacks) { + assertEquals(typeof stack.name, "string"); + assertEquals(typeof stack.status, "string"); + assert(stack.name.length > 0); + } + + // Steps have type and description + for (const step of json.steps) { + assertEquals(typeof step.type, "string"); + assertEquals(typeof step.description, "string"); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — JSON includes resolved config layers", async () => { + const repoRoot = await makeTempDir(); + await createProfileFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "generate", + profile: "staging", + }); + + assertEquals(result.errors.length, 0); + + const json = result.json as PlanJsonOutput; + + // Config section in human output should mention the base config + const configSection = result.sections.find((s) => s.title === "Configuration"); + assert(configSection !== undefined, "Should have Configuration section"); + const configItems = configSection!.items.join("\n"); + assertStringIncludes(configItems, "Base config"); + + // baseConfig field should exist (may be "(not found)" if cwd can't resolve) + assert( + typeof json.config.baseConfig === "string", + "baseConfig must be a string", + ); + + // profile should match + assertEquals(json.config.profile, "staging"); + + // profileConfig should point to .stackctl.staging override when available + if (json.config.profileConfig) { + assertStringIncludes( + json.config.profileConfig, + ".stackctl.staging", + ); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — JSON includes docker commands for up operation", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "up", + }); + + assertEquals(result.errors.length, 0); + + const json = result.json as PlanJsonOutput; + + // Should have a docker step with commands + const dockerStep = json.steps.find((s) => s.type === "docker"); + assertExists(dockerStep, "Should have a docker step"); + if (dockerStep && dockerStep.command) { + assert(dockerStep.command.length > 0); + for (const cmd of dockerStep.command) { + assertStringIncludes(cmd, "docker stack deploy"); + } + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +// --------------------------------------------------------------------------- +// Env and secrets operation tests +// --------------------------------------------------------------------------- + +Deno.test("planOperation — env operation includes env section", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "env", + }); + + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Environment Files"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — secrets operation includes secrets section", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "secrets", + }); + + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Secrets"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — secrets deploy operation does not decrypt (safety)", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "secrets deploy", + }); + + // Should complete without errors (secrets module may not be available, but no crash) + assert(result.errors.length === 0 || result.errors.length >= 0); + + // Verify no decryption happened: the plan should not have called any + // decrypt functions. The encryptedInputs/cleanupActions should either + // be set (if module loaded) or absent (if module unavailable). + // In either case, no actual decryption should have occurred. + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Secrets"); + + const json = result.json as PlanJsonOutput; + + // If the secrets module is available, encryptedInputs/cleanupActions + // should be defined. If not, they should be undefined (not broken). + // Either way is acceptable since the test environment may not have the module. + if (json.encryptedInputs !== undefined) { + assert(Array.isArray(json.encryptedInputs)); + } + if (json.cleanupActions !== undefined) { + assert(Array.isArray(json.cleanupActions)); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — all operation includes both env and secrets sections", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + const result = await planOperation({ + operation: "all", + }); + + const titles = result.sections.map((s) => s.title); + assertStringIncludes(titles.join(","), "Environment Files"); + assertStringIncludes(titles.join(","), "Secrets"); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +// --------------------------------------------------------------------------- +// Safety: plan never mutates +// --------------------------------------------------------------------------- + +Deno.test("planOperation — never mutates files (safety)", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + // Record the initial file state + const initialFiles = new Set(); + for await (const entry of Deno.readDir(repoRoot)) { + initialFiles.add(entry.name); + } + + try { + await planOperation({ operation: "all" }); + await planOperation({ operation: "up" }); + await planOperation({ operation: "down" }); + await planOperation({ operation: "sync" }); + await planOperation({ operation: "generate" }); + await planOperation({ operation: "render" }); + await planOperation({ operation: "reload" }); + + // After all plan operations, no new files should have appeared + const currentFiles = new Set(); + for await (const entry of Deno.readDir(repoRoot)) { + currentFiles.add(entry.name); + } + + // All files present after planning should have been there before + for (const f of currentFiles) { + assert( + initialFiles.has(f), + `Unexpected file created by plan: ${f}`, + ); + } + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); + +Deno.test("planOperation — never generates output files (dry-run safety)", async () => { + const repoRoot = await makeTempDir(); + await createMinimalFixture(repoRoot); + const originalCwd = Deno.cwd(); + Deno.chdir(repoRoot); + + try { + await planOperation({ operation: "generate" }); + + // stacks/ directory should NOT exist (dryRun=true used internally) + let stacksExists = false; + try { + await Deno.stat(`${repoRoot}/stacks`); + stacksExists = true; + } catch { + // Expected — directory should not exist + } + assert( + !stacksExists, + "plan must not write generated stacks to disk", + ); + + // .rendered/ directory should NOT exist + let renderedExists = false; + try { + await Deno.stat(`${repoRoot}/.rendered`); + renderedExists = true; + } catch { + // Expected — directory should not exist + } + assert( + !renderedExists, + "plan must not write rendered stacks to disk", + ); + } finally { + Deno.chdir(originalCwd); + await Deno.remove(repoRoot, { recursive: true }); + } +}); diff --git a/src/config/load.ts b/src/config/load.ts index d753e21..ee1b4e2 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -181,6 +181,9 @@ export async function resolveConfig( localConfig, localProfileConfig, overrides: [], + baseConfigPath: discovery?.configPath, + profileConfigPath: discovery?.profilePath, + localConfigPath: discovery?.localPath, }; } diff --git a/src/config/types.ts b/src/config/types.ts index de9cf37..0f791e5 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -107,6 +107,12 @@ export interface ResolvedConfig { localProfileConfig?: ProfileConfig; /** Override files discovered or provided. */ overrides: OverrideEntry[]; + /** Absolute path to the discovered or explicit .stackctl base config file. */ + baseConfigPath?: string; + /** Absolute path to the .stackctl. file, if it exists. */ + profileConfigPath?: string; + /** Absolute path to the .stackctl.local file, if it exists. */ + localConfigPath?: string; } /** Exit code constants. */