Last active
September 17, 2025 10:34
-
-
Save oxc/2a05703efe7963217e3d77cbee614e69 to your computer and use it in GitHub Desktop.
Script to replace CloudFormation ImportValue references with their actual resolved values. This helps break hard dependencies between stacks during development.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ts-node | |
| /** | |
| * Script to replace CloudFormation ImportValue references with their actual resolved values. | |
| * This helps break hard dependencies between stacks during development. | |
| * | |
| * Usage: | |
| * npm install aws-sdk commander | |
| * npx tsx scripts/resolve-import-values.ts myStack.template -i '*SomeConstructPattern*' | |
| * AWS_PROFILE=my-profile npx tsx scripts/resolve-import-values.ts --stack MyStack -o myStack.template -i '*SomeConstructPattern*' | |
| */ | |
| import { readFileSync, writeFileSync } from "fs"; | |
| import { | |
| CloudFormationClient, | |
| GetTemplateCommand, | |
| paginateListExports, | |
| paginateListStacks, | |
| } from "@aws-sdk/client-cloudformation"; | |
| import { Command } from "commander"; | |
| import { minimatch } from "minimatch"; | |
| interface CloudFormationTemplate { | |
| [key: string]: unknown; | |
| } | |
| interface ExportMap { | |
| [exportName: string]: string; | |
| } | |
| interface ReplaceResult { | |
| updatedTemplate: unknown; | |
| foundImports: string[]; | |
| } | |
| class ImportResolver { | |
| private readonly cfClient: CloudFormationClient; | |
| constructor(region: string, profile?: string) { | |
| this.cfClient = new CloudFormationClient({ | |
| region, | |
| ...(profile && { profile }), | |
| }); | |
| } | |
| async getExportedValues(): Promise<ExportMap> { | |
| console.log("Fetching CloudFormation exports..."); | |
| const exports: ExportMap = {}; | |
| try { | |
| const paginator = paginateListExports({ client: this.cfClient }, {}); | |
| for await (const page of paginator) { | |
| if (page.Exports) { | |
| for (const exportItem of page.Exports) { | |
| if (exportItem.Name && exportItem.Value) { | |
| exports[exportItem.Name] = exportItem.Value; | |
| } | |
| } | |
| } | |
| } | |
| console.log(`Found ${Object.keys(exports).length} exports`); | |
| return exports; | |
| } catch (error) { | |
| throw new Error(`Error fetching exports: ${error}`); | |
| } | |
| } | |
| async listStackNames(): Promise<string[]> { | |
| try { | |
| const stacks: string[] = []; | |
| const paginator = paginateListStacks({ client: this.cfClient }, {}); | |
| for await (const page of paginator) { | |
| if (page.StackSummaries) { | |
| stacks.push( | |
| ...page.StackSummaries.filter((s) => typeof s.StackName === "string" && typeof s.StackId === "string").map( | |
| (s) => `${s.StackName} (${s.StackId})`, | |
| ), | |
| ); | |
| } | |
| } | |
| return stacks; | |
| } catch (err) { | |
| console.error("Error listing stacks:", err); | |
| return []; | |
| } | |
| } | |
| async getTemplateFromStack(stackName: string): Promise<CloudFormationTemplate> { | |
| console.log(`Fetching template for stack: ${stackName}`); | |
| try { | |
| const command = new GetTemplateCommand({ StackName: stackName }); | |
| const response = await this.cfClient.send(command); | |
| if (!response.TemplateBody) throw new Error("No template body returned"); | |
| return JSON.parse(response.TemplateBody); | |
| } catch (error) { | |
| // On error, show available stack names for debugging | |
| const stackNames = await this.listStackNames(); | |
| throw new Error( | |
| `Error fetching template from stack: ${error}\nAvailable stacks in region: ${stackNames.length > 0 ? stackNames.join(", ") : "(none found or permission denied)"}`, | |
| ); | |
| } | |
| } | |
| async loadTemplate( | |
| templateFile?: string, | |
| stackName?: string, | |
| isStack: boolean = false, | |
| ): Promise<CloudFormationTemplate> { | |
| if (isStack && stackName) { | |
| return await this.getTemplateFromStack(stackName); | |
| } else if (templateFile) { | |
| try { | |
| const templateContent = readFileSync(templateFile, "utf-8"); | |
| return JSON.parse(templateContent); | |
| } catch (error) { | |
| throw new Error(`Error reading template: ${error}`); | |
| } | |
| } else { | |
| throw new Error("No template source provided"); | |
| } | |
| } | |
| replaceImportValues( | |
| template: unknown, | |
| exports: ExportMap, | |
| targetImports?: string[], | |
| path: string = "", | |
| ): ReplaceResult { | |
| const foundImports: string[] = []; | |
| // Helper: match import name against patterns | |
| const matchesPattern = (importName: string) => { | |
| if (!targetImports || targetImports.length === 0) return true; | |
| return targetImports.some((pattern) => minimatch(importName, pattern)); | |
| }; | |
| if (typeof template === "object" && template !== null) { | |
| if (Array.isArray(template)) { | |
| const updatedArray: unknown[] = []; | |
| template.forEach((item, index) => { | |
| const result = this.replaceImportValues(item, exports, targetImports, `${path}[${index}]`); | |
| updatedArray.push(result.updatedTemplate); | |
| foundImports.push(...result.foundImports); | |
| }); | |
| return { updatedTemplate: updatedArray, foundImports }; | |
| } else { | |
| if ("Fn::ImportValue" in template) { | |
| const importName = template["Fn::ImportValue"]; | |
| if (typeof importName === "object" && importName !== null && "Fn::Sub" in importName) { | |
| console.log(`Warning: Complex import value at ${path}: ${JSON.stringify(importName)}`); | |
| console.log("You may need to manually resolve this."); | |
| return { updatedTemplate: template, foundImports }; | |
| } else if (typeof importName === "string") { | |
| foundImports.push(importName); | |
| // Check if we should replace this specific import using pattern matching | |
| const shouldReplace = matchesPattern(importName); | |
| if (shouldReplace && importName in exports) { | |
| console.log(`Replacing import '${importName}' with value '${exports[importName]}'`); | |
| return { updatedTemplate: exports[importName], foundImports }; | |
| } else if (shouldReplace && !(importName in exports)) { | |
| console.log(`ERROR: Import '${importName}' not found in exports!`); | |
| return { updatedTemplate: template, foundImports }; | |
| } else { | |
| console.log(`Skipping import '${importName}' (does not match any pattern)`); | |
| return { updatedTemplate: template, foundImports }; | |
| } | |
| } | |
| } else { | |
| const result: Record<string, unknown> = {}; | |
| Object.entries(template).forEach(([key, value]) => { | |
| const childResult = this.replaceImportValues(value, exports, targetImports, `${path}.${key}`); | |
| result[key] = childResult.updatedTemplate; | |
| foundImports.push(...childResult.foundImports); | |
| }); | |
| return { updatedTemplate: result, foundImports }; | |
| } | |
| } | |
| } | |
| return { updatedTemplate: template, foundImports }; | |
| } | |
| async processTemplate( | |
| template: CloudFormationTemplate, | |
| outputPath: string, | |
| dryRun: boolean = false, | |
| targetImports?: string[], | |
| ): Promise<void> { | |
| // Get all exports | |
| const exports = await this.getExportedValues(); | |
| // Process template and collect imports in one pass | |
| const result = this.replaceImportValues(template, exports, targetImports); | |
| if (result.foundImports.length === 0) { | |
| console.log("No ImportValue references found in template"); | |
| return; | |
| } | |
| console.log(`\nFound ${result.foundImports.length} ImportValue references:`); | |
| const uniqueImports = [...new Set(result.foundImports)]; | |
| uniqueImports.forEach((importVal) => { | |
| const isTarget = | |
| !targetImports || targetImports.length === 0 || targetImports.some((pattern) => minimatch(importVal, pattern)); | |
| const hasExport = importVal in exports; | |
| if (isTarget && hasExport) { | |
| console.log(` ✓ ${importVal} -> ${exports[importVal]} (REPLACED)`); | |
| } else if (isTarget && !hasExport) { | |
| console.log(` ✗ ${importVal} (NOT FOUND IN EXPORTS)`); | |
| } else { | |
| console.log(` - ${importVal} (SKIPPED - does not match any pattern)`); | |
| } | |
| }); | |
| if (targetImports && targetImports.length > 0) { | |
| const foundTargets = uniqueImports.filter((imp) => targetImports.some((pattern) => minimatch(imp, pattern))); | |
| const missingTargets = targetImports.filter((pattern) => !uniqueImports.some((imp) => minimatch(imp, pattern))); | |
| if (missingTargets.length > 0) { | |
| console.error(`\nError: No imports found matching patterns: ${missingTargets.join(", ")}`); | |
| return; | |
| } | |
| console.log(`\nTargeted ${foundTargets.length} out of ${targetImports.length} specified patterns`); | |
| } | |
| if (dryRun) { | |
| console.log("\nDry run mode - no changes made"); | |
| return; | |
| } | |
| console.log("\nImport values replaced in template."); | |
| // Write output | |
| const outputFile = outputPath; | |
| try { | |
| if (!outputFile) { | |
| throw new Error("No output file specified"); | |
| } | |
| writeFileSync(outputFile, JSON.stringify(result.updatedTemplate, null, 2)); | |
| console.log(`Updated template written to ${outputFile}`); | |
| } catch (error) { | |
| throw new Error(`Error writing output: ${error}`); | |
| } | |
| } | |
| } | |
| async function main() { | |
| const program = new Command(); | |
| program | |
| .name("resolve-imports") | |
| .description("Replace CloudFormation ImportValue with resolved values") | |
| .argument("[templateFile]", "Path to CloudFormation template file (optional if --stack is used)") | |
| .option("-o, --output <file>", "Output file (default: overwrite input)") | |
| .option("-i, --imports <imports...>", "Specific import names to replace (space-separated)") | |
| .option("-r, --region <region>", "AWS region") | |
| .option("-p, --profile <profile>", "AWS profile to use") | |
| .option("--dry-run", "Show what would be replaced without making changes", false) | |
| .option("--stack <stackName>", "CloudFormation stack name to fetch template from") | |
| .showHelpAfterError() | |
| .parse(); | |
| const options = program.opts(); | |
| const templateFile = program.args[0]; | |
| try { | |
| const resolver = new ImportResolver(options.region, options.profile); | |
| const isStack = Boolean(options.stack); | |
| const outputPath = options.output || templateFile; | |
| if (!outputPath) { | |
| program.error("No output file specified"); | |
| return; | |
| } | |
| program.showHelpAfterError(false); | |
| const template = await resolver.loadTemplate(templateFile, options.stack, isStack); | |
| await resolver.processTemplate(template, outputPath, options.dry_run, options.imports); | |
| } catch (error) { | |
| program.error(`${error}`); | |
| } | |
| } | |
| if (require.main === module) { | |
| main().catch((error) => { | |
| console.error("Unhandled error:", error); | |
| process.exit(1); | |
| }); | |
| } | |
| export { ImportResolver }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment