packages/@aws-cdk/toolkit-lib/lib/api/resource-import/importer.ts (269 lines of code) (raw):
import { format } from 'util';
import * as cfnDiff from '@aws-cdk/cloudformation-diff';
import type { ResourceDifference } from '@aws-cdk/cloudformation-diff';
import type * as cxapi from '@aws-cdk/cx-api';
import type { ResourceIdentifierSummary, ResourceToImport } from '@aws-sdk/client-cloudformation';
import * as chalk from 'chalk';
import * as fs from 'fs-extra';
import * as promptly from 'promptly';
import type { DeploymentMethod, Deployments } from '../deployments';
import { assertIsSuccessfulDeployStackResult } from '../deployments';
import { IO, type IoHelper } from '../io/private';
import type { Tag } from '../tags';
import { ToolkitError } from '../toolkit-error';
export type ResourcesToImport = ResourceToImport[];
export type ResourceIdentifierSummaries = ResourceIdentifierSummary[];
export interface ResourceImporterProps {
deployments: Deployments;
ioHelper: IoHelper;
}
export interface ImportDeploymentOptions {
/**
* Role to pass to CloudFormation for deployment
*
* @default - Default stack role
*/
readonly roleArn?: string;
/**
* Deployment method
*
* @default - Change set with default options
*/
readonly deploymentMethod?: DeploymentMethod;
/**
* Stack tags (pass through to CloudFormation)
*
* @default - No tags
*/
readonly tags?: Tag[];
/**
* Use previous values for unspecified parameters
*
* If not set, all parameters must be specified for every deployment.
*
* @default true
*/
readonly usePreviousParameters?: boolean;
/**
* Rollback failed deployments
*
* @default true
*/
readonly rollback?: boolean;
}
/**
* Set of parameters that uniquely identify a physical resource of a given type
* for the import operation, example:
*
* ```
* {
* "AWS::S3::Bucket": [["BucketName"]],
* "AWS::DynamoDB::GlobalTable": [["TableName"], ["TableArn"], ["TableStreamArn"]],
* "AWS::Route53::KeySigningKey": [["HostedZoneId", "Name"]],
* }
* ```
*/
export type ResourceIdentifiers = { [resourceType: string]: string[][] };
type ResourceIdentifierProperties = Record<string, string>;
/**
* Mapping of CDK resources (L1 constructs) to physical resources to be imported
* in their place, example:
*
* ```
* {
* "MyStack/MyS3Bucket/Resource": {
* "BucketName": "my-manually-created-s3-bucket"
* },
* "MyStack/MyVpc/Resource": {
* "VpcId": "vpc-123456789"
* }
* }
* ```
*/
type ResourceMap = { [logicalResource: string]: ResourceIdentifierProperties };
/**
* Resource importing utility class
*
* - Determines the resources added to a template (compared to the deployed version)
* - Look up the identification information
* - Load them from a file, or
* - Ask the user, based on information supplied to us by CloudFormation's GetTemplateSummary
* - Translate the input to a structure expected by CloudFormation, update the template to add the
* importable resources, then run an IMPORT changeset.
*/
export class ResourceImporter {
private _currentTemplate: any;
private readonly stack: cxapi.CloudFormationStackArtifact;
private readonly cfn: Deployments;
private readonly ioHelper: IoHelper;
constructor(
stack: cxapi.CloudFormationStackArtifact,
props: ResourceImporterProps,
) {
this.stack = stack;
this.cfn = props.deployments;
this.ioHelper = props.ioHelper;
}
/**
* Ask the user for resources to import
*/
public async askForResourceIdentifiers(available: ImportableResource[]): Promise<ImportMap> {
const ret: ImportMap = { importResources: [], resourceMap: {} };
const resourceIdentifiers = await this.resourceIdentifiers();
for (const resource of available) {
const identifier = await this.askForResourceIdentifier(resourceIdentifiers, resource);
if (!identifier) {
continue;
}
ret.importResources.push(resource);
ret.resourceMap[resource.logicalId] = identifier;
}
return ret;
}
/**
* Load the resources to import from a file
*/
public async loadResourceIdentifiers(available: ImportableResource[], filename: string): Promise<ImportMap> {
const contents = await fs.readJson(filename);
const ret: ImportMap = { importResources: [], resourceMap: {} };
for (const resource of available) {
const descr = this.describeResource(resource.logicalId);
const idProps = contents[resource.logicalId];
if (idProps) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format('%s: importing using %s', chalk.blue(descr), chalk.blue(fmtdict(idProps)))));
ret.importResources.push(resource);
ret.resourceMap[resource.logicalId] = idProps;
delete contents[resource.logicalId];
} else {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(format('%s: skipping', chalk.blue(descr))));
}
}
const unknown = Object.keys(contents);
if (unknown.length > 0) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`Unrecognized resource identifiers in mapping file: ${unknown.join(', ')}`));
}
return ret;
}
/**
* Based on the provided resource mapping, prepare CFN structures for import (template,
* ResourcesToImport structure) and perform the import operation (CloudFormation deployment)
*
* @param importMap Mapping from CDK construct tree path to physical resource import identifiers
* @param options Options to pass to CloudFormation deploy operation
*/
public async importResourcesFromMap(importMap: ImportMap, options: ImportDeploymentOptions = {}) {
const resourcesToImport: ResourcesToImport = await this.makeResourcesToImport(importMap);
const updatedTemplate = await this.currentTemplateWithAdditions(importMap.importResources);
await this.importResources(updatedTemplate, resourcesToImport, options);
}
/**
* Based on the app and resources file generated by cdk migrate. Removes all items from the template that
* cannot be included in an import change-set for new stacks and performs the import operation,
* creating the new stack.
*
* @param resourcesToImport The mapping created by cdk migrate
* @param options Options to pass to CloudFormation deploy operation
*/
public async importResourcesFromMigrate(resourcesToImport: ResourcesToImport, options: ImportDeploymentOptions = {}) {
const updatedTemplate = this.removeNonImportResources();
await this.importResources(updatedTemplate, resourcesToImport, options);
}
private async importResources(overrideTemplate: any, resourcesToImport: ResourcesToImport, options: ImportDeploymentOptions) {
try {
const result = await this.cfn.deployStack({
stack: this.stack,
deployName: this.stack.stackName,
...options,
overrideTemplate,
resourcesToImport,
});
assertIsSuccessfulDeployStackResult(result);
const message = result.noOp
? ' ✅ %s (no changes)'
: ' ✅ %s';
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg('\n' + chalk.green(format(message, this.stack.displayName))));
} catch (e) {
await this.ioHelper.notify(IO.CDK_TOOLKIT_E3900.msg(format('\n ❌ %s failed: %s', chalk.bold(this.stack.displayName), e), { error: e as any }));
throw e;
}
}
/**
* Perform a diff between the currently running and the new template, ensure that it is valid
* for importing and return a list of resources that are being added in the new version
*
* @return mapping logicalResourceId -> resourceDifference
*/
public async discoverImportableResources(allowNonAdditions = false): Promise<DiscoverImportableResourcesResult> {
const currentTemplate = await this.currentTemplate();
const diff = cfnDiff.fullDiff(currentTemplate, this.stack.template);
// Ignore changes to CDKMetadata
const resourceChanges = Object.entries(diff.resources.changes)
.filter(([logicalId, _]) => logicalId !== 'CDKMetadata');
// Split the changes into additions and non-additions. Imports only make sense
// for newly-added resources.
const nonAdditions = resourceChanges.filter(([_, dif]) => !dif.isAddition);
const additions = resourceChanges.filter(([_, dif]) => dif.isAddition);
if (nonAdditions.length) {
const offendingResources = nonAdditions.map(([logId, _]) => this.describeResource(logId));
if (allowNonAdditions) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`Ignoring updated/deleted resources (--force): ${offendingResources.join(', ')}`));
} else {
throw new ToolkitError('No resource updates or deletes are allowed on import operation. Make sure to resolve pending changes ' +
`to existing resources, before attempting an import. Updated/deleted resources: ${offendingResources.join(', ')} (--force to override)`);
}
}
// Resources in the new template, that are not present in the current template, are a potential import candidates
return {
additions: additions.map(([logicalId, resourceDiff]) => ({
logicalId,
resourceDiff,
resourceDefinition: addDefaultDeletionPolicy(this.stack.template?.Resources?.[logicalId] ?? {}),
})),
hasNonAdditions: nonAdditions.length > 0,
};
}
/**
* Resolves the environment of a stack.
*/
public async resolveEnvironment(): Promise<cxapi.Environment> {
return this.cfn.resolveEnvironment(this.stack);
}
/**
* Get currently deployed template of the given stack (SINGLETON)
*
* @returns Currently deployed CloudFormation template
*/
private async currentTemplate(): Promise<any> {
if (!this._currentTemplate) {
this._currentTemplate = await this.cfn.readCurrentTemplate(this.stack);
}
return this._currentTemplate;
}
/**
* Return the current template, with the given resources added to it
*/
private async currentTemplateWithAdditions(additions: ImportableResource[]): Promise<any> {
const template = await this.currentTemplate();
if (!template.Resources) {
template.Resources = {};
}
for (const add of additions) {
template.Resources[add.logicalId] = add.resourceDefinition;
}
return template;
}
/**
* Get a list of import identifiers for all resource types used in the given
* template that do support the import operation (SINGLETON)
*
* @returns a mapping from a resource type to a list of property names that together identify the resource for import
*/
private async resourceIdentifiers(): Promise<ResourceIdentifiers> {
const ret: ResourceIdentifiers = {};
const resourceIdentifierSummaries = await this.cfn.resourceIdentifierSummaries(this.stack);
for (const summary of resourceIdentifierSummaries) {
if ('ResourceType' in summary && summary.ResourceType && 'ResourceIdentifiers' in summary && summary.ResourceIdentifiers) {
ret[summary.ResourceType] = (summary.ResourceIdentifiers ?? [])?.map(x => x.split(','));
}
}
return ret;
}
/**
* Ask for the importable identifier for the given resource
*
* There may be more than one identifier under which a resource can be imported. The `import`
* operation needs exactly one of them.
*
* - If we can get one from the template, we will use one.
* - Otherwise, we will ask the user for one of them.
*/
private async askForResourceIdentifier(
resourceIdentifiers: ResourceIdentifiers,
chg: ImportableResource,
): Promise<ResourceIdentifierProperties | undefined> {
const resourceName = this.describeResource(chg.logicalId);
// Skip resources that do not support importing
const resourceType = chg.resourceDiff.newResourceType;
if (resourceType === undefined || !(resourceType in resourceIdentifiers)) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(`${resourceName}: unsupported resource type ${resourceType}, skipping import.`));
return undefined;
}
const idPropSets = resourceIdentifiers[resourceType];
// Retain only literal strings: strip potential CFN intrinsics
const resourceProps = Object.fromEntries(Object.entries(chg.resourceDefinition.Properties ?? {})
.filter(([_, v]) => typeof v === 'string')) as Record<string, string>;
// Find property sets that are fully satisfied in the template, ask the user to confirm them
const satisfiedPropSets = idPropSets.filter(ps => ps.every(p => resourceProps[p]));
for (const satisfiedPropSet of satisfiedPropSets) {
const candidateProps = Object.fromEntries(satisfiedPropSet.map(p => [p, resourceProps[p]]));
const displayCandidateProps = fmtdict(candidateProps);
if (await promptly.confirm(
`${chalk.blue(resourceName)} (${resourceType}): import with ${chalk.yellow(displayCandidateProps)} (yes/no) [default: yes]? `,
{ default: 'yes' },
)) {
return candidateProps;
}
}
// If we got here and the user rejected any available identifiers, then apparently they don't want the resource at all
if (satisfiedPropSets.length > 0) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(chalk.grey(`Skipping import of ${resourceName}`)));
return undefined;
}
// We cannot auto-import this, ask the user for one of the props
// The only difference between these cases is what we print: for multiple properties, we print a preamble
const prefix = `${chalk.blue(resourceName)} (${resourceType})`;
let preamble;
let promptPattern;
if (idPropSets.length > 1) {
preamble = `${prefix}: enter one of ${idPropSets.map(x => chalk.blue(x.join('+'))).join(', ')} to import (all empty to skip)`;
promptPattern = `${prefix}: enter %`;
} else {
promptPattern = `${prefix}: enter %`;
}
// Do the input loop here
if (preamble) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(preamble));
}
for (const idProps of idPropSets) {
const input: Record<string, string> = {};
for (const idProp of idProps) {
// If we have a value from the template, use it as default. This will only be a partial
// identifier if present, otherwise we would have done the import already above.
const defaultValue = resourceProps[idProp] ?? '';
const prompt = [
promptPattern.replace(/%/g, chalk.blue(idProp)),
defaultValue
? `[${defaultValue}]`
: '(empty to skip)',
].join(' ') + ':';
const response = await promptly.prompt(prompt,
{ default: defaultValue, trim: true },
);
if (!response) {
break;
}
input[idProp] = response;
// Also stick this property into 'resourceProps', so that it may be reused by a subsequent question
// (for a different compound identifier that involves the same property). Just a small UX enhancement.
resourceProps[idProp] = response;
}
// If the user gave inputs for all values, we are complete
if (Object.keys(input).length === idProps.length) {
return input;
}
}
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(chalk.grey(`Skipping import of ${resourceName}`)));
return undefined;
}
/**
* Convert the internal "resource mapping" structure to CloudFormation accepted "ResourcesToImport" structure
*/
private async makeResourcesToImport(resourceMap: ImportMap): Promise<ResourcesToImport> {
return resourceMap.importResources.map(res => ({
LogicalResourceId: res.logicalId,
ResourceType: res.resourceDiff.newResourceType!,
ResourceIdentifier: resourceMap.resourceMap[res.logicalId],
}));
}
/**
* Convert CloudFormation logical resource ID to CDK construct tree path
*
* @param logicalId CloudFormation logical ID of the resource (the key in the template's Resources section)
* @returns Forward-slash separated path of the resource in CDK construct tree, e.g. MyStack/MyBucket/Resource
*/
private describeResource(logicalId: string): string {
return this.stack.template?.Resources?.[logicalId]?.Metadata?.['aws:cdk:path'] ?? logicalId;
}
/**
* Removes CDKMetadata and Outputs in the template so that only resources for importing are left.
* @returns template with import resources only
*/
private removeNonImportResources() {
return removeNonImportResources(this.stack);
}
}
/**
* Information about a resource in the template that is importable
*/
export interface ImportableResource {
/**
* The logical ID of the resource
*/
readonly logicalId: string;
/**
* The resource definition in the new template
*/
readonly resourceDefinition: any;
/**
* The diff as reported by `cloudformation-diff`.
*/
readonly resourceDiff: ResourceDifference;
}
/**
* The information necessary to execute an import operation
*/
export interface ImportMap {
/**
* Mapping logical IDs to physical names
*/
readonly resourceMap: ResourceMap;
/**
* The selection of resources we are actually importing
*
* For each of the resources in this list, there is a corresponding entry in
* the `resourceMap` map.
*/
readonly importResources: ImportableResource[];
}
function fmtdict<A>(xs: Record<string, A>) {
return Object.entries(xs).map(([k, v]) => `${k}=${v}`).join(', ');
}
/**
* Add a default `DeletionPolicy` policy.
* The default value is set to 'Retain', to lower risk of unintentionally
* deleting stateful resources in the process of importing to CDK.
*/
function addDefaultDeletionPolicy(resource: any): any {
if (resource.DeletionPolicy) {
return resource;
}
return {
...resource,
DeletionPolicy: 'Retain',
};
}
export interface DiscoverImportableResourcesResult {
readonly additions: ImportableResource[];
readonly hasNonAdditions: boolean;
}
export function removeNonImportResources(stack:cxapi.CloudFormationStackArtifact) {
const template = stack.template;
delete template.Resources.CDKMetadata;
delete template.Outputs;
return template;
}