@owloops/cdko
Version:
Multi-region AWS CDK deployment tool
808 lines (801 loc) • 26.8 kB
JavaScript
// 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