UNPKG

@owloops/cdko

Version:

Multi-region AWS CDK deployment tool

808 lines (801 loc) 26.8 kB
// src/core/stack-manager.ts import { $ } from "zx"; import fs from "fs/promises"; import { minimatch } from "minimatch"; // src/utils/logger.ts import { chalk } from "zx"; var logger = { error: (msg) => console.error(chalk.red("x"), msg), warn: (msg) => console.log(chalk.yellow("!"), msg), info: (msg) => console.log(chalk.blue("\u2022"), msg), success: (msg) => console.log(chalk.green("\u2713"), msg), region: (region) => chalk.cyan(region), stack: (stackName) => chalk.bold(stackName), dim: (text) => chalk.dim(text) }; // src/core/stack-manager.ts var StackManager = class { configPath; constructor(configPath = ".cdko.json") { this.configPath = configPath; } async detect() { try { await fs.access("cdk.json"); } catch { throw new Error( "No CDK project found. Run this command in a CDK project directory." ); } const configExists = await fs.access(this.configPath).then(() => true).catch(() => false); if (configExists) { logger.info("Updating existing CDKO configuration..."); } else { logger.info("Creating new CDKO configuration..."); } const config = await this.detectStacks(); await this.saveConfig(config); const action = configExists ? "updated" : "created"; logger.success(`CDKO configuration ${action} successfully!`); logger.info(`Found ${Object.keys(config.stackGroups).length} stack groups`); return config; } async loadConfig() { try { const data = await fs.readFile(this.configPath, "utf8"); const config = JSON.parse(data); if (config.stackGroups && typeof config.stackGroups !== "object") { logger.warn("Config: stackGroups must be an object, ignoring"); config.stackGroups = void 0; } return config; } catch (error) { if (error instanceof Error && "code" in error && error.code !== "ENOENT") { logger.warn(`Failed to load ${this.configPath}: ${error.message}`); } } return {}; } async detectStacks() { logger.info("Detecting CDK stacks..."); try { const result = await $`cdk list --long --json`.quiet(); const stacks = JSON.parse(result.stdout); if (!Array.isArray(stacks) || stacks.length === 0) { throw new Error( "No stacks found. Ensure your CDK app synthesizes correctly." ); } const stackGroups = {}; for (const stack of stacks) { const stackName = stack.name || stack.id; const account = stack.environment?.account; const region = stack.environment?.region; let constructId = stack.id; const parenIndex = constructId.indexOf(" ("); if (parenIndex > -1) { constructId = constructId.substring(0, parenIndex); } if (!stackGroups[stackName]) { stackGroups[stackName] = {}; } const deploymentKey = `${account}/${region}`; stackGroups[stackName][deploymentKey] = { constructId, account, region }; } return { version: "0.1", stackGroups, cdkTimeout: process.env.CDK_TIMEOUT, suppressNotices: process.env.CDK_CLI_NOTICES !== "true", lastUpdated: (/* @__PURE__ */ new Date()).toISOString(), updatedBy: `cdko@${await this.getCdkoVersion()}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("No stacks found")) { throw error; } logger.error(`Stack detection failed: ${errorMessage}`); throw new Error( "Failed to detect stacks. Ensure CDK app synthesizes correctly." ); } } async getCdkoVersion() { try { const packagePath = new URL("../../package.json", import.meta.url); const data = await fs.readFile(packagePath, "utf8"); const packageJson = JSON.parse(data); return packageJson.version || "0.0.0"; } catch { return "0.0.0"; } } async saveConfig(config) { await fs.writeFile(this.configPath, JSON.stringify(config, null, 2) + "\n"); logger.info(`Configuration saved to ${this.configPath}`); } getRegions(config, cliRegions) { if (cliRegions === "all") { if (config?.stackGroups) { const allRegions = Object.values(config.stackGroups).flatMap( (group) => Object.values(group).map((deployment) => deployment.region) ); const uniqueRegions = [...new Set(allRegions)]; if (uniqueRegions.length > 0) { return uniqueRegions; } } return ["us-east-1"]; } return cliRegions.split(",").map((r) => r.trim()); } matchStacks(stackPattern, stackConfig) { if (!stackConfig?.stackGroups) { return []; } const patterns = stackPattern.split(",").map((p) => p.trim()); const matchedGroups = []; for (const pattern of patterns) { for (const [stackGroupName, deployments] of Object.entries( stackConfig.stackGroups )) { if (minimatch(stackGroupName, pattern)) { matchedGroups.push({ name: stackGroupName, pattern, deployments }); } } } return matchedGroups; } filterDeployments(stackGroup, requestedRegions) { const filteredDeployments = []; for (const [, deployment] of Object.entries(stackGroup.deployments)) { const { region, constructId } = deployment; if (region === "unknown-region") { for (const requestedRegion of requestedRegions) { filteredDeployments.push({ region: requestedRegion, constructId, stackName: stackGroup.name }); } } else { if (requestedRegions.includes(region)) { filteredDeployments.push({ region, constructId, stackName: stackGroup.name }); } } } return filteredDeployments; } resolveDeployments(stackPattern, stackConfig, requestedRegions) { const matchedGroups = this.matchStacks(stackPattern, stackConfig); if (matchedGroups.length === 0) { logger.warn(`No stacks found matching pattern: ${stackPattern}`); return []; } logger.info(`Found ${matchedGroups.length} stack(s) matching pattern`); const allDeployments = []; for (const stackGroup of matchedGroups) { const deployments = this.filterDeployments(stackGroup, requestedRegions); if (deployments.length === 0) { logger.warn( `No valid deployments for ${stackGroup.name} in requested regions` ); } else { logger.info( `${stackGroup.name}: ${deployments.length} deployment(s) planned` ); allDeployments.push(...deployments); } } return allDeployments; } }; // src/core/account-manager.ts import { $ as $2 } from "zx"; import { minimatch as minimatch2 } from "minimatch"; var AccountManager = class { configPath; accountCache; constructor(configPath = ".cdko.json") { this.configPath = configPath; this.accountCache = /* @__PURE__ */ new Map(); } async getAvailableProfiles() { try { const result = await $2`aws configure list-profiles`.quiet(); return result.stdout.split("\n").map((profile) => profile.trim()).filter((profile) => profile.length > 0); } catch { logger.warn("Could not list AWS profiles, using exact matching only"); return []; } } async matchProfiles(profilePattern) { const patterns = profilePattern.split(",").map((p) => p.trim()); const availableProfiles = await this.getAvailableProfiles(); const matchedProfiles = []; for (const pattern of patterns) { if (availableProfiles.length > 0) { for (const profile of availableProfiles) { if (minimatch2(profile, pattern)) { if (!matchedProfiles.includes(profile)) { matchedProfiles.push(profile); } } } } else { if (!matchedProfiles.includes(pattern)) { matchedProfiles.push(pattern); } } } if (matchedProfiles.length === 0) { logger.warn(`No profiles found matching pattern: ${profilePattern}`); return patterns; } logger.info( `Found ${matchedProfiles.length} profile(s) matching pattern: ${matchedProfiles.join(", ")}` ); return matchedProfiles; } async getAccountInfo(profile) { if (this.accountCache.has(profile)) { return this.accountCache.get(profile); } try { logger.info(`Discovering account for profile: ${profile}`); const result = await $2`aws sts get-caller-identity --profile ${profile} --output json`.quiet(); const identity = JSON.parse(result.stdout); const accountInfo = { profile, accountId: identity.Account, userId: identity.UserId, arn: identity.Arn }; this.accountCache.set(profile, accountInfo); logger.info(`Profile ${profile} \u2192 Account ${identity.Account}`); return accountInfo; } catch (error) { const errorMsg = error instanceof Error && "stderr" in error ? error.stderr : error instanceof Error ? error.message : String(error); logger.error( `Failed to get account info for profile ${profile}: ${errorMsg}` ); throw new Error( `Profile '${profile}' authentication failed: ${errorMsg}` ); } } async getMultiAccountInfo(profiles) { logger.info(`Discovering accounts for ${profiles.length} profile(s)...`); const accountPromises = profiles.map( (profile) => this.getAccountInfo(profile).catch( (error) => ({ profile, error: error instanceof Error ? error.message : String(error), failed: true }) ) ); const results = await Promise.all(accountPromises); const successful = results.filter( (result) => !("failed" in result) ); const failed = results.filter( (result) => "failed" in result ); if (failed.length > 0) { logger.error(`Failed to authenticate ${failed.length} profile(s):`); failed.forEach(({ profile, error }) => { logger.error(` ${profile}: ${error}`); }); if (successful.length === 0) { throw new Error("All profiles failed authentication"); } logger.warn(`Continuing with ${successful.length} successful profile(s)`); } return successful; } createDeploymentTargets(accountInfo, regions) { const targets = []; accountInfo.forEach(({ profile, accountId }) => { regions.forEach((region) => { targets.push({ profile, accountId, region, key: `${accountId}/${region}` }); }); }); return targets; } clearCache() { this.accountCache.clear(); } }; // src/core/cloud-assembly.ts import { $ as $3 } from "zx"; import { existsSync, mkdirSync, rmSync } from "fs"; import { join } from "path"; var CloudAssemblyManager = class { cloudAssemblyPath; constructor() { this.cloudAssemblyPath = null; } getCloudAssemblyPath() { return join(process.cwd(), "cdk.out"); } async synthesize(options = {}) { const { profile, environment, outputDir } = options; const cloudAssemblyPath = outputDir ? join(process.cwd(), outputDir) : this.getCloudAssemblyPath(); try { if (existsSync(cloudAssemblyPath)) { logger.info(`Cleaning existing cloud assembly at ${cloudAssemblyPath}`); rmSync(cloudAssemblyPath, { recursive: true, force: true }); } mkdirSync(cloudAssemblyPath, { recursive: true }); logger.info("Synthesizing cloud assembly..."); const cdkArgs = ["synth"]; if (process.env.CDK_CLI_NOTICES !== "true") { cdkArgs.push("--no-notices"); } cdkArgs.push("--output", cloudAssemblyPath); if (profile) { cdkArgs.push("--profile", profile); } if (environment) { cdkArgs.push("--context", `environment=${environment}`); } const result = await $3({ quiet: true })`cdk ${cdkArgs}`; if (result.exitCode === 0) { logger.success(`Cloud assembly synthesized to ${cloudAssemblyPath}`); this.cloudAssemblyPath = cloudAssemblyPath; return cloudAssemblyPath; } else { throw new Error(`CDK synth failed with exit code ${result.exitCode}`); } } catch (error) { logger.error("Failed to synthesize cloud assembly"); if (error instanceof Error && "stderr" in error) { console.error(error.stderr); } throw error; } } getCdkArgs(baseArgs) { if (!this.cloudAssemblyPath || !existsSync(this.cloudAssemblyPath)) { throw new Error("Cloud assembly not available. Run synthesize() first."); } const args = [...baseArgs]; const appIndex = args.indexOf("--app"); if (appIndex !== -1) { args.splice(appIndex, 2); } args.push("--app", this.cloudAssemblyPath); return args; } isAvailable() { return !!this.cloudAssemblyPath && existsSync(this.cloudAssemblyPath); } }; // src/core/executor.ts import { $ as $4 } from "zx"; async function runCdkCommand(region, stackName, command, profile, options = {}) { const { verbose = false, parameters = [], includeDeps = false, context = [], executeChangeset = false, cdkOptions = "", signal, cloudAssemblyPath = null } = options; const cdkArgs = [command, "--profile", profile]; if (process.env.CDK_CLI_NOTICES !== "true") { cdkArgs.push("--no-notices"); } if (verbose) { cdkArgs.push("-v"); } if (!cloudAssemblyPath) { throw new Error("Cloud assembly path is required"); } cdkArgs.push("--app", cloudAssemblyPath); context.forEach((ctx) => { cdkArgs.push("--context", ctx); }); if (command === "deploy") { if (!includeDeps) cdkArgs.push("--exclusively"); if (!executeChangeset) cdkArgs.push("--no-execute"); cdkArgs.push("--require-approval=never"); if (executeChangeset) { cdkArgs.push("--progress", "events"); } } if (command === "diff") { if (!includeDeps) cdkArgs.push("--exclusively"); } parameters.forEach((param) => { if (includeDeps && !param.includes(":")) { cdkArgs.push("--parameters", `${stackName}:${param}`); } else { cdkArgs.push("--parameters", param); } }); if (cdkOptions) { const additionalArgs = cdkOptions.split(/\s+/).filter((arg) => arg); cdkArgs.push(...additionalArgs); } cdkArgs.push(stackName); process.env.AWS_REGION = region; process.env.FORCE_COLOR = "1"; const cdkProcess = $4({ signal, quiet: ["diff", "deploy"].includes(command) ? false : !verbose })`cdk ${cdkArgs}`; return process.env.CDK_TIMEOUT ? await cdkProcess.timeout(process.env.CDK_TIMEOUT) : await cdkProcess; } // src/core/orchestrator.ts async function deployStack(deployment, args, signal, cloudAssemblyPath = null) { const { region, constructId, stackName, profile } = deployment; const deployProfile = profile || args.profile; console.log(); console.log(`${logger.region(region)} \u2192 ${logger.stack(stackName)}`); if (args.dryRun) { logger.info(`Would deploy: ${constructId} to ${region}`); return { success: true, region, stackName }; } const startTime = Date.now(); try { const executorOptions = { ...args, signal, cloudAssemblyPath }; switch (args.mode) { case "diff": await runCdkCommand( region, constructId, "diff", deployProfile, executorOptions ); break; case "changeset": logger.info(`Creating changeset for ${stackName}`); await runCdkCommand( region, constructId, "deploy", deployProfile, executorOptions ); logger.success("Changeset created"); break; case "execute": logger.info(`Deploying ${stackName}`); const executeOptions = { ...executorOptions, executeChangeset: true }; await runCdkCommand( region, constructId, "deploy", deployProfile, executeOptions ); logger.success(`Deployed ${stackName}`); break; } const duration = ((Date.now() - startTime) / 1e3).toFixed(1); logger.info(`Completed in ${duration}s`); return { success: true, region, stackName, duration }; } catch (e) { logger.error(`Failed to deploy ${stackName}`); return { success: false, region, stackName, error: e }; } } async function deployToAllRegions(regions, args, signal) { const stackManager = new StackManager(); const stackConfig = await stackManager.loadConfig(); let deployments = []; const isMultiAccount = args.profile.includes(",") || args.profile.includes("*") || args.profile.includes("{"); if (isMultiAccount) { deployments = await resolveMultiAccountDeployments( args.profile, args.stackPattern, stackConfig, regions ); } else { if (stackConfig) { deployments = stackManager.resolveDeployments( args.stackPattern, stackConfig, regions ); if (deployments.length === 0) { logger.warn( "No matching stacks found in .cdko.json, falling back to traditional deployment" ); } } if (deployments.length === 0) { logger.info("Using traditional pattern-based deployment"); for (const region of regions) { const stackName = args.stackPattern; deployments.push({ region, constructId: stackName, stackName, profile: args.profile }); } } else { deployments = deployments.map((deployment) => ({ ...deployment, profile: args.profile })); } } const profileAssemblies = /* @__PURE__ */ new Map(); if (isMultiAccount) { const uniqueProfiles = [ ...new Set(deployments.map((d) => d.profile || args.profile)) ]; logger.info( `Synthesizing cloud assemblies for ${uniqueProfiles.length} profile(s)...` ); const synthesisPromises = uniqueProfiles.map(async (profile) => { try { const cloudAssembly = new CloudAssemblyManager(); const assemblyPath = await cloudAssembly.synthesize({ profile, environment: args.environment, outputDir: `cdk.out-${profile}` }); profileAssemblies.set(profile, assemblyPath); logger.success( `Cloud assembly for profile ${profile} \u2192 ${assemblyPath}` ); return { profile, assemblyPath, success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error( `Failed to synthesize for profile ${profile}: ${errorMessage}` ); return { profile, error, success: false }; } }); const synthesisResults = await Promise.all(synthesisPromises); const failedSynthesis = synthesisResults.filter( (result) => !result.success ); if (failedSynthesis.length > 0) { throw new Error( `Failed to synthesize ${failedSynthesis.length} profile(s): ${failedSynthesis.map((f) => f.profile).join(", ")}` ); } } else { try { const cloudAssembly = new CloudAssemblyManager(); const assemblyPath = await cloudAssembly.synthesize({ profile: args.profile, environment: args.environment }); profileAssemblies.set(args.profile, assemblyPath); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to synthesize cloud assembly: ${errorMessage}`); throw error; } } if (!isMultiAccount) { console.log(); logger.info(`Planning to deploy ${deployments.length} stack(s):`); const stackGroups = {}; deployments.forEach((d) => { if (!stackGroups[d.stackName]) { stackGroups[d.stackName] = []; } stackGroups[d.stackName].push(d.region); }); Object.entries(stackGroups).forEach(([stack, regions2]) => { logger.info(`${stack} \u2192 ${regions2.join(", ")}`); }); } const results = []; if (args.sequential) { for (const deployment of deployments) { const assemblyPath = profileAssemblies.get( deployment.profile || args.profile ); results.push(await deployStack(deployment, args, signal, assemblyPath)); } } else { logger.info( `Processing ${deployments.length} deployment(s) in parallel...` ); const deploymentPromises = deployments.map((deployment) => { const assemblyPath = profileAssemblies.get( deployment.profile || args.profile ); return deployStack(deployment, args, signal, assemblyPath).then( (result) => ({ ...result, status: "fulfilled" }), (error) => ({ region: deployment.region, stackName: deployment.stackName, success: false, error, status: "rejected" }) ); }); const parallelResults = await Promise.all(deploymentPromises); results.push(...parallelResults); console.log(); results.forEach((result) => { if (result.success) { logger.success( `${result.region}: ${result.stackName} completed successfully` ); } else { const error = result.error; let errorMsg = "Unknown error"; if (error instanceof Error) { errorMsg = error.message; } else if (error && typeof error === "object" && "stderr" in error) { errorMsg = error.stderr; } else if (error && typeof error === "object" && "stdout" in error) { errorMsg = error.stdout; } else if (typeof error === "string") { errorMsg = error; } const lines = errorMsg.split("\n").filter((line) => line.trim()); const meaningfulError = lines.find( (line) => line.includes("No stacks match") || line.includes("already exists") || line.includes("AccessDenied") || line.includes("is not authorized") || line.includes("CloudFormation error") || line.includes("Error:") || line.includes("failed:") ) || lines[0] || "Check output above for details"; logger.error( `${result.region}: ${result.stackName} - ${meaningfulError.trim()}` ); } }); } return results; } async function resolveMultiAccountDeployments(profilePattern, stackPattern, stackConfig, requestedRegions) { const accountManager = new AccountManager(); const profiles = await accountManager.matchProfiles(profilePattern); if (profiles.length === 0) { throw new Error(`No profiles found matching pattern: ${profilePattern}`); } const accountInfo = await accountManager.getMultiAccountInfo(profiles); if (accountInfo.length === 0) { throw new Error("Failed to authenticate any profiles"); } const deploymentTargets = accountManager.createDeploymentTargets( accountInfo, requestedRegions ); const stackManager = new StackManager(); const matchedStacks = stackManager.matchStacks(stackPattern, stackConfig); const allDeployments = []; if (matchedStacks.length === 0) { logger.info("No stack configuration found, using pattern-based deployment"); deploymentTargets.forEach(({ profile, accountId, region }) => { allDeployments.push({ profile, accountId, region, constructId: stackPattern, stackName: stackPattern, source: "pattern" }); }); } else { logger.info(`Found ${matchedStacks.length} stack(s) matching pattern`); matchedStacks.forEach((stackGroup) => { deploymentTargets.forEach(({ profile, accountId, region, key }) => { if (stackGroup.deployments[key]) { const deployment = stackGroup.deployments[key]; allDeployments.push({ profile, accountId, region, constructId: deployment.constructId, stackName: stackGroup.name, source: "configured" }); } else { const unknownRegionKey = `${accountId}/unknown-region`; if (stackGroup.deployments[unknownRegionKey]) { const deployment = stackGroup.deployments[unknownRegionKey]; allDeployments.push({ profile, accountId, region, constructId: deployment.constructId, stackName: stackGroup.name, source: "region-agnostic" }); } } }); }); } if (allDeployments.length === 0) { logger.warn( "No deployments resolved - check stack configuration and account/region combinations" ); return []; } logMultiAccountDeploymentSummary(allDeployments); return allDeployments; } function logMultiAccountDeploymentSummary(deployments) { console.log(); logger.info(`Planning ${deployments.length} deployment(s):`); const stackGroups = {}; deployments.forEach((deployment) => { if (!stackGroups[deployment.stackName]) { stackGroups[deployment.stackName] = []; } stackGroups[deployment.stackName].push(deployment); }); Object.entries(stackGroups).forEach(([stackName, stackDeployments]) => { const targets = stackDeployments.map((d) => `${d.accountId}/${d.region}`).join(", "); logger.info(`${stackName} \u2192 ${targets}`); }); const accounts = [...new Set(deployments.map((d) => d.accountId))]; const profiles = [...new Set(deployments.map((d) => d.profile))]; console.log(); logger.info(`Using ${profiles.length} profile(s): ${profiles.join(", ")}`); logger.info( `Targeting ${accounts.length} account(s): ${accounts.join(", ")}` ); } // src/utils/prerequisites.ts import { $ as $5 } from "zx"; async function checkPrerequisites() { const requiredTools = ["aws", "cdk"]; const missing = []; for (const tool of requiredTools) { try { await $5`which ${tool}`.quiet(); } catch { missing.push(tool); } } if (missing.length > 0) { logger.error(`Missing required tools: ${missing.join(", ")}`); logger.info("Please install the missing tools to continue"); process.exit(1); } try { const cdkVersion = await $5`cdk --version`.quiet(); logger.info(`Using CDK version: ${cdkVersion.toString().trim()}`); } catch { } } export { AccountManager, CloudAssemblyManager, StackManager, checkPrerequisites, deployToAllRegions, logger, runCdkCommand }; //# sourceMappingURL=index.js.map