Skip to content

Commit

Permalink
fix(migrate-template-gen): resolve all outputs
Browse files Browse the repository at this point in the history
  • Loading branch information
abhi7cr committed Oct 8, 2024
1 parent fecb791 commit b9fced5
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 59 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import CategoryTemplateGenerator from './category-template-generator';
import { CFN_S3_TYPE, CFNTemplate } from './types';
import { CloudFormationClient, DescribeStacksCommand, GetTemplateCommand, Parameter } from '@aws-sdk/client-cloudformation';
import {
CloudFormationClient,
DescribeStacksCommand,
DescribeStacksOutput,
GetTemplateCommand,
GetTemplateOutput,
Parameter,
} from '@aws-sdk/client-cloudformation';

const mockCfnClientSendMock = jest.fn();

Expand Down Expand Up @@ -230,6 +237,7 @@ const gen1Params: Parameter[] = [
ParameterValue: 'dev',
},
];

const refactoredGen1Template: CFNTemplate = {
...newGen1Template,
Resources: {
Expand Down Expand Up @@ -285,44 +293,47 @@ const refactoredGen2Template: CFNTemplate = {
},
};

const generateDescribeStacksResponse = (command: DescribeStacksCommand): DescribeStacksOutput => ({
Stacks: [
{
StackId: command.input.StackName,
StackName: command.input.StackName,
Capabilities: ['CAPABILITY_NAMED_IAM'],
Tags: [
{
Key: 'amplify:category-stack',
Value: 'amplify-testauth-dev-12345-auth-ABCDE',
},
],
CreationTime: new Date(),
LastUpdatedTime: new Date(),
StackStatus: 'CREATE_COMPLETE',
Parameters: gen1Params,
Outputs: [
{
OutputKey: 'BucketNameOutputRef',
OutputValue: 'my-test-bucket-dev',
Description: 'My bucket',
},
],
},
],
});

const generateGetTemplateResponse = (command: GetTemplateCommand): GetTemplateOutput => ({
TemplateBody: command.input.StackName === GEN1_CATEGORY_STACK_ID ? JSON.stringify(oldGen1Template) : JSON.stringify(oldGen2Template),
});

jest.mock('@aws-sdk/client-cloudformation', () => {
return {
...jest.requireActual('@aws-sdk/client-cloudformation'),
CloudFormationClient: function () {
return {
send: mockCfnClientSendMock.mockImplementation((command) => {
if (command instanceof DescribeStacksCommand) {
return Promise.resolve({
Stacks: [
{
StackId: command.input.StackName,
Capabilities: ['CAPABILITY_NAMED_IAM'],
Tags: [
{
Key: 'amplify:category-stack',
Value: 'amplify-testauth-dev-12345-auth-ABCDE',
},
],
CreationTime: new Date(),
LastUpdatedTime: new Date(),
DeletionTime: null,
StackStatus: 'CREATE_COMPLETE',
Parameters: gen1Params,
Outputs: [
{
OutputKey: 'BucketNameOutputRef',
OutputValue: 'my-test-bucket-dev',
Description: 'My bucket',
},
],
},
],
});
return Promise.resolve(generateDescribeStacksResponse(command));
} else if (command instanceof GetTemplateCommand) {
return Promise.resolve({
TemplateBody:
command.input.StackName === GEN1_CATEGORY_STACK_ID ? JSON.stringify(oldGen1Template) : JSON.stringify(oldGen2Template),
});
return Promise.resolve(generateGetTemplateResponse(command));
}
return Promise.resolve({});
}),
Expand All @@ -331,6 +342,9 @@ jest.mock('@aws-sdk/client-cloudformation', () => {
};
});

const oldGen1TemplateWithoutS3Bucket = JSON.parse(JSON.stringify(oldGen1Template)) as CFNTemplate;
delete oldGen1TemplateWithoutS3Bucket.Resources[GEN1_S3_BUCKET_LOGICAL_ID];

describe('CategoryTemplateGenerator', () => {
const s3TemplateGenerator = new CategoryTemplateGenerator(
GEN1_CATEGORY_STACK_ID,
Expand All @@ -352,6 +366,15 @@ describe('CategoryTemplateGenerator', () => {
(resourcesToMove, resourceEntry) => resourcesToMove.includes(CFN_S3_TYPE.Bucket) && resourceEntry[0] === GEN1_S3_BUCKET_LOGICAL_ID,
);

const noGen1ResourcesToMoveS3TemplateGenerator = new CategoryTemplateGenerator(
GEN1_CATEGORY_STACK_ID,
GEN2_CATEGORY_STACK_ID,
'us-east-1',
'12345',
new CloudFormationClient(),
[CFN_S3_TYPE.Bucket],
);

it('should preprocess gen1 template prior to refactor', async () => {
await expect(s3TemplateGenerator.generateGen1PreProcessTemplate()).resolves.toEqual({
oldTemplate: oldGen1Template,
Expand Down Expand Up @@ -392,4 +415,22 @@ describe('CategoryTemplateGenerator', () => {
]),
);
});

it('should throw error when there are no resources to move', async () => {
const sendFailureMock = (command: any) => {
if (command instanceof DescribeStacksCommand) {
return Promise.resolve(generateDescribeStacksResponse(command));
} else if (command instanceof GetTemplateCommand) {
return Promise.resolve({
TemplateBody:
command.input.StackName === GEN1_CATEGORY_STACK_ID
? JSON.stringify(oldGen1TemplateWithoutS3Bucket)
: JSON.stringify(oldGen2Template),
});
}
return Promise.resolve({});
};
mockCfnClientSendMock.mockImplementationOnce(sendFailureMock).mockImplementationOnce(sendFailureMock);
await expect(noGen1ResourcesToMoveS3TemplateGenerator.generateGen1PreProcessTemplate()).rejects.toThrowError();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class CategoryTemplateGenerator<CFNCategoryType extends CFN_CATEGORY_TYPE> {
);
}),
);
// validate empty resources
if (this.gen1ResourcesToMove.size === 0) throw new Error('No resources to move in Gen1 stack.');
const logicalResourceIds = [...this.gen1ResourcesToMove.keys()];
const gen1ParametersResolvedTemplate = new CfnParameterResolver(oldGen1Template).resolve(Parameters);
const gen1TemplateWithOutputsResolved = new CfnOutputResolver(gen1ParametersResolvedTemplate, this.region, this.accountId).resolve(
Expand All @@ -58,14 +60,15 @@ class CategoryTemplateGenerator<CFNCategoryType extends CFN_CATEGORY_TYPE> {
this.gen2DescribeStacksResponse = await this.describeStack(this.gen2StackId);
assert(this.gen2DescribeStacksResponse);
const { Parameters, Outputs } = this.gen2DescribeStacksResponse;
assert(Parameters);
assert(Outputs);
const oldGen2Template = await this.readTemplate(this.gen2StackId);
this.gen2ResourcesToRemove = new Map(
Object.entries(oldGen2Template.Resources).filter(([, value]) =>
this.resourcesToMove.some((resourceToMove) => resourceToMove.valueOf() === value.Type),
),
);
// validate empty resources
if (this.gen2ResourcesToRemove.size === 0) throw new Error('No resources to remove in Gen2 stack.');
const updatedGen2Template = this.removeGen2ResourcesFromGen2Stack(oldGen2Template, [...this.gen2ResourcesToRemove.keys()]);
return {
oldTemplate: oldGen2Template,
Expand Down
1 change: 1 addition & 0 deletions packages/amplify-migration-template-gen/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './template-generator';
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ aws cloudformation update-stack \\
--stack-name amplify-mygen2app-test-sandbox-12345-auth-ABCDE \\
--template-body file://test/step2-gen2ResourcesRemovalStackTemplate.json \\
--parameters '[{"ParameterKey":"authSelections","ParameterValue":"identityPoolAndUserPool"}]' \\
--capabilities CAPABILITY_NAMED_IAM
--capabilities CAPABILITY_NAMED_IAM \\
--tags '[]'
\`\`\`
\`\`\`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ aws cloudformation update-stack \\
--stack-name ${this.gen2CategoryStackName} \\
--template-body file://${step2FileNamePath} \\
--parameters '${paramsString}' \\
--capabilities CAPABILITY_NAMED_IAM
--capabilities CAPABILITY_NAMED_IAM \\
--tags '[]'
\`\`\`
\`\`\`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class CFNConditionResolver {
public resolve(parameters: Parameter[]) {
if (!this.conditions || Object.keys(this.conditions).length === 0) return this.template;

const clonedGen1Template = JSON.parse(JSON.stringify(this.template)) as CFNTemplate;
const clonedTemplate = JSON.parse(JSON.stringify(this.template)) as CFNTemplate;
const conditionValueMap = new Map<string, boolean>();
Object.entries(this.conditions).forEach(([conditionKey, conditionValue]) => {
const fnType = Object.keys(conditionValue)[0];
Expand All @@ -28,9 +28,9 @@ class CFNConditionResolver {
}
});

this.resolveConditionInResources(clonedGen1Template.Resources, conditionValueMap);
this.resolveConditionInResources(clonedTemplate.Resources, conditionValueMap);

return clonedGen1Template;
return clonedTemplate;
}

private resolveCondition(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,29 @@ class CfnOutputResolver {
assert(stackTemplateOutputs);
let stackTemplateResourcesString = JSON.stringify(stackTemplateResources);

for (const logicalResourceId of logicalResourceIds) {
Object.entries(stackTemplateOutputs).forEach(([outputKey, outputValue]) => {
const value = outputValue.Value;
const stackOutputValue = stackOutputs?.find((op) => op.OutputKey === outputKey)?.OutputValue;
assert(stackOutputValue);
Object.entries(stackTemplateOutputs).forEach(([outputKey, outputValue]) => {
const value = outputValue.Value;
const stackOutputValue = stackOutputs?.find((op) => op.OutputKey === outputKey)?.OutputValue;
assert(stackOutputValue);

// Replace logicalId references using stack output values
if (typeof value === 'object' && REF in value && value[REF] === logicalResourceId) {
const outputRegexp = new RegExp(`{"${REF}":"${logicalResourceId}"}`, 'g');
stackTemplateResourcesString = stackTemplateResourcesString.replaceAll(outputRegexp, `"${stackOutputValue}"`);
// Replace logicalId references using stack output values
if (typeof value === 'object' && REF in value) {
const logicalResourceId = value[REF] as string;
const outputRegexp = new RegExp(`{"${REF}":"${logicalResourceId}"}`, 'g');
stackTemplateResourcesString = stackTemplateResourcesString.replaceAll(outputRegexp, `"${stackOutputValue}"`);

// Replace Fn:GetAtt references using stack output values
const fnGetAttRegExp = new RegExp(`{"${GET_ATT}":\\["${logicalResourceId}","(?<AttributeName>\\w+)"]}`, 'g');
const fnGetAttRegExpResult = stackTemplateResourcesString.matchAll(fnGetAttRegExp).next();
const resourceType = this.template.Resources[logicalResourceId].Type as CFN_RESOURCE_TYPES;
if (!fnGetAttRegExpResult.done) {
const attributeName = fnGetAttRegExpResult.value.groups?.AttributeName;
assert(attributeName);
const resource = this.getResourceAttribute(attributeName as AWS_RESOURCE_ATTRIBUTES, resourceType, stackOutputValue);
stackTemplateResourcesString = stackTemplateResourcesString.replaceAll(fnGetAttRegExp, this.buildFnGetAttReplace(resource));
}
// Replace Fn:GetAtt references using stack output values
const fnGetAttRegExp = new RegExp(`{"${GET_ATT}":\\["${logicalResourceId}","(?<AttributeName>\\w+)"]}`, 'g');
const fnGetAttRegExpResult = stackTemplateResourcesString.matchAll(fnGetAttRegExp).next();
const resourceType = this.template.Resources[logicalResourceId].Type as CFN_RESOURCE_TYPES;
if (!fnGetAttRegExpResult.done) {
const attributeName = fnGetAttRegExpResult.value.groups?.AttributeName;
assert(attributeName);
const resource = this.getResourceAttribute(attributeName as AWS_RESOURCE_ATTRIBUTES, resourceType, stackOutputValue);
stackTemplateResourcesString = stackTemplateResourcesString.replaceAll(fnGetAttRegExp, this.buildFnGetAttReplace(resource));
}
});
}
}
});

clonedStackTemplate.Resources = JSON.parse(stackTemplateResourcesString);
Object.entries(clonedStackTemplate.Outputs).forEach(([outputKey]) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ class CfnParameterResolver {

public resolve(parameters: Parameter[]) {
if (!parameters.length) return this.template;
let templateString = JSON.stringify(this.template);
const clonedGen1Template = JSON.parse(JSON.stringify(this.template)) as CFNTemplate;
let templateString = JSON.stringify(clonedGen1Template);
const parametersFromTemplate = this.template.Parameters;
for (const { ParameterKey, ParameterValue } of parameters) {
assert(ParameterKey);
Expand Down

0 comments on commit b9fced5

Please sign in to comment.