Skip to content

Commit e85cc3a

Browse files
authored
feat: generate stack definitions for refactoring (#477)
Having a list of deployed stacks, and the mappings (either computed or user provided), we have to generate a list of stack definitions to be sent to the `createStackRefactor` API. The function `generateStackDefinitions` is not yet used anywhere, other than unit tests. Future PRs will use it to make the API call. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 9d9315c commit e85cc3a

File tree

2 files changed

+366
-5
lines changed

2 files changed

+366
-5
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { StackDefinition } from '@aws-sdk/client-cloudformation';
2+
import type { CloudFormationStack, ResourceMapping } from './cloudformation';
3+
import { ToolkitError } from '../../toolkit/toolkit-error';
4+
5+
/**
6+
* Generates a list of stack definitions to be sent to the CloudFormation API
7+
* by applying each mapping to the corresponding stack template(s).
8+
*/
9+
export function generateStackDefinitions(mappings: ResourceMapping[], deployedStacks: CloudFormationStack[]): StackDefinition[] {
10+
const templates = Object.fromEntries(
11+
deployedStacks
12+
.filter((s) =>
13+
mappings.some(
14+
(m) =>
15+
// We only care about stacks that are part of the mappings
16+
m.source.stack.stackName === s.stackName || m.destination.stack.stackName === s.stackName,
17+
),
18+
)
19+
.map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template))]),
20+
);
21+
22+
mappings.forEach((mapping) => {
23+
const sourceStackName = mapping.source.stack.stackName;
24+
const sourceLogicalId = mapping.source.logicalResourceId;
25+
const sourceTemplate = templates[sourceStackName];
26+
27+
const destinationStackName = mapping.destination.stack.stackName;
28+
const destinationLogicalId = mapping.destination.logicalResourceId;
29+
if (templates[destinationStackName] == null) {
30+
// The API doesn't allow anything in the template other than the resources
31+
// that are part of the mappings. So we need to create an empty template
32+
// to start adding resources to.
33+
templates[destinationStackName] = { Resources: {} };
34+
}
35+
const destinationTemplate = templates[destinationStackName];
36+
37+
// Do the move
38+
destinationTemplate.Resources[destinationLogicalId] = sourceTemplate.Resources[sourceLogicalId];
39+
delete sourceTemplate.Resources[sourceLogicalId];
40+
});
41+
42+
// CloudFormation doesn't allow empty stacks
43+
for (const [stackName, template] of Object.entries(templates)) {
44+
if (Object.keys(template.Resources ?? {}).length === 0) {
45+
throw new ToolkitError(`Stack ${stackName} has no resources after refactor. You must add a resource to this stack. This resource can be a simple one, like a waitCondition resource type.`);
46+
}
47+
}
48+
49+
return Object.entries(templates).map(([stackName, template]) => ({
50+
StackName: stackName,
51+
TemplateBody: JSON.stringify(template),
52+
}));
53+
}

packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts

Lines changed: 313 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@ import {
1717
resourceMappings,
1818
resourceMovements,
1919
} from '../../../lib/api/refactoring';
20-
import type {
21-
ResourceLocation,
22-
ResourceMapping,
23-
CloudFormationStack,
24-
} from '../../../lib/api/refactoring/cloudformation';
20+
import type { CloudFormationStack } from '../../../lib/api/refactoring/cloudformation';
21+
import { ResourceLocation, ResourceMapping } from '../../../lib/api/refactoring/cloudformation';
2522
import { computeResourceDigests } from '../../../lib/api/refactoring/digest';
23+
import { generateStackDefinitions } from '../../../lib/api/refactoring/execution';
2624
import { mockCloudFormationClient, MockSdkProvider } from '../../_helpers/mock-sdk';
2725

2826
const cloudFormationClient = mockCloudFormationClient;
@@ -1380,6 +1378,316 @@ describe('environment grouping', () => {
13801378
});
13811379
});
13821380

1381+
describe(generateStackDefinitions, () => {
1382+
const environment = {
1383+
name: 'test',
1384+
account: '333333333333',
1385+
region: 'us-east-1',
1386+
};
1387+
1388+
test('renames a resource within the same stack', () => {
1389+
const stack: CloudFormationStack = {
1390+
environment: environment,
1391+
stackName: 'Foo',
1392+
template: {
1393+
Resources: {
1394+
Bucket1: {
1395+
Type: 'AWS::S3::Bucket',
1396+
},
1397+
NotInvolved: {
1398+
Type: 'AWS::X::Y',
1399+
},
1400+
},
1401+
},
1402+
};
1403+
1404+
const mappings: ResourceMapping[] = [
1405+
new ResourceMapping(new ResourceLocation(stack, 'Bucket1'), new ResourceLocation(stack, 'Bucket2')),
1406+
];
1407+
1408+
const result = generateStackDefinitions(mappings, [stack]);
1409+
expect(result).toEqual([
1410+
{
1411+
StackName: 'Foo',
1412+
TemplateBody: JSON.stringify({
1413+
Resources: {
1414+
// Not involved in the refactor, but still part of the
1415+
// original template. Should be included.
1416+
NotInvolved: {
1417+
Type: 'AWS::X::Y',
1418+
},
1419+
Bucket2: {
1420+
Type: 'AWS::S3::Bucket',
1421+
},
1422+
},
1423+
}),
1424+
},
1425+
]);
1426+
});
1427+
1428+
test('moves a resource to another stack that has already been deployed', () => {
1429+
const stack1: CloudFormationStack = {
1430+
environment,
1431+
stackName: 'Stack1',
1432+
template: {
1433+
Resources: {
1434+
Bucket1: {
1435+
Type: 'AWS::S3::Bucket',
1436+
},
1437+
A: {
1438+
Type: 'AWS::A::A',
1439+
},
1440+
},
1441+
},
1442+
};
1443+
1444+
const stack2: CloudFormationStack = {
1445+
environment,
1446+
stackName: 'Stack2',
1447+
template: {
1448+
Resources: {
1449+
B: {
1450+
Type: 'AWS::B::B',
1451+
},
1452+
},
1453+
},
1454+
};
1455+
1456+
const mappings: ResourceMapping[] = [
1457+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')),
1458+
];
1459+
1460+
const result = generateStackDefinitions(mappings, [stack1, stack2]);
1461+
expect(result).toEqual([
1462+
{
1463+
StackName: 'Stack1',
1464+
TemplateBody: JSON.stringify({
1465+
Resources: {
1466+
// Wasn't touched by the refactor
1467+
A: {
1468+
Type: 'AWS::A::A',
1469+
},
1470+
1471+
// Bucket1 doesn't exist anymore
1472+
},
1473+
}),
1474+
},
1475+
{
1476+
StackName: 'Stack2',
1477+
TemplateBody: JSON.stringify({
1478+
Resources: {
1479+
// Wasn't touched by the refactor
1480+
B: {
1481+
Type: 'AWS::B::B',
1482+
},
1483+
1484+
// Old Bucket1 is now Bucket2 here
1485+
Bucket2: {
1486+
Type: 'AWS::S3::Bucket',
1487+
},
1488+
},
1489+
}),
1490+
},
1491+
]);
1492+
});
1493+
1494+
test('moves a resource to another stack that has not been deployed', () => {
1495+
const stack1: CloudFormationStack = {
1496+
environment,
1497+
stackName: 'Stack1',
1498+
template: {
1499+
Resources: {
1500+
Bucket1: {
1501+
Type: 'AWS::S3::Bucket',
1502+
},
1503+
A: {
1504+
Type: 'AWS::A::A',
1505+
},
1506+
},
1507+
},
1508+
};
1509+
1510+
const stack2: CloudFormationStack = {
1511+
environment,
1512+
stackName: 'Stack2',
1513+
template: {
1514+
Resources: {
1515+
B: {
1516+
Type: 'AWS::B::B',
1517+
},
1518+
},
1519+
},
1520+
};
1521+
1522+
const mappings: ResourceMapping[] = [
1523+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')),
1524+
];
1525+
1526+
const result = generateStackDefinitions(mappings, [stack1]);
1527+
expect(result).toEqual([
1528+
{
1529+
StackName: 'Stack1',
1530+
TemplateBody: JSON.stringify({
1531+
Resources: {
1532+
// Wasn't touched by the refactor
1533+
A: {
1534+
Type: 'AWS::A::A',
1535+
},
1536+
1537+
// Bucket1 doesn't exist anymore
1538+
},
1539+
}),
1540+
},
1541+
{
1542+
StackName: 'Stack2',
1543+
TemplateBody: JSON.stringify({
1544+
Resources: {
1545+
// Old Bucket1 is now Bucket2 here
1546+
Bucket2: {
1547+
Type: 'AWS::S3::Bucket',
1548+
},
1549+
},
1550+
}),
1551+
},
1552+
]);
1553+
});
1554+
1555+
test('multiple mappings', () => {
1556+
const stack1: CloudFormationStack = {
1557+
environment,
1558+
stackName: 'Stack1',
1559+
template: {
1560+
Resources: {
1561+
Bucket1: {
1562+
Type: 'AWS::S3::Bucket',
1563+
},
1564+
Bucket2: {
1565+
Type: 'AWS::S3::Bucket',
1566+
},
1567+
},
1568+
},
1569+
};
1570+
1571+
const stack2: CloudFormationStack = {
1572+
environment,
1573+
stackName: 'Stack2',
1574+
template: {
1575+
Resources: {
1576+
Bucket3: {
1577+
Type: 'AWS::S3::Bucket',
1578+
},
1579+
},
1580+
},
1581+
};
1582+
1583+
const mappings: ResourceMapping[] = [
1584+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket4')),
1585+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket2'), new ResourceLocation(stack2, 'Bucket5')),
1586+
new ResourceMapping(new ResourceLocation(stack2, 'Bucket3'), new ResourceLocation(stack1, 'Bucket6')),
1587+
];
1588+
1589+
const result = generateStackDefinitions(mappings, [stack1, stack2]);
1590+
expect(result).toEqual([
1591+
{
1592+
StackName: 'Stack1',
1593+
TemplateBody: JSON.stringify({
1594+
Resources: {
1595+
Bucket6: {
1596+
Type: 'AWS::S3::Bucket',
1597+
},
1598+
},
1599+
}),
1600+
},
1601+
{
1602+
StackName: 'Stack2',
1603+
TemplateBody: JSON.stringify({
1604+
Resources: {
1605+
Bucket4: {
1606+
Type: 'AWS::S3::Bucket',
1607+
},
1608+
Bucket5: {
1609+
Type: 'AWS::S3::Bucket',
1610+
},
1611+
},
1612+
}),
1613+
},
1614+
]);
1615+
});
1616+
1617+
test('deployed stacks that are not in any mapping', () => {
1618+
const stack1: CloudFormationStack = {
1619+
environment,
1620+
stackName: 'Stack1',
1621+
template: {
1622+
Resources: {
1623+
Bucket1: {
1624+
Type: 'AWS::S3::Bucket',
1625+
},
1626+
},
1627+
},
1628+
};
1629+
1630+
const stack2: CloudFormationStack = {
1631+
environment,
1632+
stackName: 'Stack2',
1633+
template: {
1634+
Resources: {
1635+
Bucket2: {
1636+
Type: 'AWS::S3::Bucket',
1637+
},
1638+
},
1639+
},
1640+
};
1641+
1642+
const mappings: ResourceMapping[] = [
1643+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket3')),
1644+
];
1645+
1646+
const result = generateStackDefinitions(mappings, [stack1, stack2]);
1647+
expect(result).toEqual([
1648+
{
1649+
StackName: 'Stack1',
1650+
TemplateBody: JSON.stringify({
1651+
Resources: {
1652+
Bucket3: {
1653+
Type: 'AWS::S3::Bucket',
1654+
},
1655+
},
1656+
}),
1657+
},
1658+
]);
1659+
});
1660+
1661+
test('refactor should not create empty templates', () => {
1662+
const stack1: CloudFormationStack = {
1663+
environment,
1664+
stackName: 'Stack1',
1665+
template: {
1666+
Resources: {
1667+
Bucket1: {
1668+
Type: 'AWS::S3::Bucket',
1669+
},
1670+
},
1671+
},
1672+
};
1673+
1674+
const stack2: CloudFormationStack = {
1675+
environment,
1676+
stackName: 'Stack2',
1677+
template: {
1678+
Resources: {},
1679+
},
1680+
};
1681+
1682+
const mappings: ResourceMapping[] = [
1683+
new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')),
1684+
];
1685+
1686+
expect(() => generateStackDefinitions(mappings, [stack1, stack2]))
1687+
.toThrow(/Stack Stack1 has no resources after refactor/);
1688+
});
1689+
});
1690+
13831691
function toCfnMapping(m: ResourceMapping): CfnResourceMapping {
13841692
return {
13851693
Source: toCfnLocation(m.source),

0 commit comments

Comments
 (0)