Skip to content

Commit 7b28155

Browse files
authored
fix: topologically sort CloudFormation resources before provisioning (#332)
1 parent a0ed51e commit 7b28155

File tree

2 files changed

+323
-5
lines changed

2 files changed

+323
-5
lines changed

src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import java.time.Instant;
1717
import java.util.*;
18+
import java.util.regex.Matcher;
19+
import java.util.regex.Pattern;
1820
import java.util.concurrent.ConcurrentHashMap;
1921
import java.util.concurrent.ExecutorService;
2022
import java.util.concurrent.Executors;
@@ -219,11 +221,10 @@ private void executeTemplate(Stack stack, String templateBody, Map<String, Strin
219221
}
220222

221223
if (resources.isObject()) {
222-
Iterator<Map.Entry<String, JsonNode>> it = resources.fields();
223-
while (it.hasNext()) {
224-
var entry = it.next();
225-
String logicalId = entry.getKey();
226-
JsonNode resDef = entry.getValue();
224+
List<String> sortedLogicalIds = topologicalSort(resources, conditions);
225+
226+
for (String logicalId : sortedLogicalIds) {
227+
JsonNode resDef = resources.get(logicalId);
227228
String type = resDef.path("Type").asText();
228229
JsonNode props = resDef.path("Properties");
229230

@@ -482,6 +483,146 @@ private Stack getStackOrThrow(String stackName, String region) {
482483
return stack;
483484
}
484485

486+
private List<String> topologicalSort(JsonNode resources, Map<String, Boolean> conditions) {
487+
Set<String> allIds = new LinkedHashSet<>();
488+
resources.fieldNames().forEachRemaining(allIds::add);
489+
490+
Map<String, Set<String>> dependencies = new HashMap<>();
491+
for (String logicalId : allIds) {
492+
JsonNode resDef = resources.get(logicalId);
493+
494+
String condition = resDef.path("Condition").asText(null);
495+
if (condition != null && !conditions.getOrDefault(condition, false)) {
496+
continue;
497+
}
498+
499+
Set<String> deps = new LinkedHashSet<>();
500+
collectDependencies(resDef.path("Properties"), allIds, deps);
501+
502+
JsonNode dependsOn = resDef.path("DependsOn");
503+
if (dependsOn.isTextual()) {
504+
deps.add(dependsOn.asText());
505+
} else if (dependsOn.isArray()) {
506+
for (JsonNode d : dependsOn) {
507+
deps.add(d.asText());
508+
}
509+
}
510+
511+
dependencies.put(logicalId, deps);
512+
}
513+
514+
Map<String, Integer> inDegree = new HashMap<>();
515+
for (String id : allIds) {
516+
inDegree.put(id, 0);
517+
}
518+
for (var entry : dependencies.entrySet()) {
519+
for (String dep : entry.getValue()) {
520+
if (inDegree.containsKey(dep)) {
521+
inDegree.put(entry.getKey(), inDegree.get(entry.getKey()) + 1);
522+
}
523+
}
524+
}
525+
526+
Deque<String> queue = new ArrayDeque<>();
527+
for (String id : allIds) {
528+
if (inDegree.get(id) == 0) {
529+
queue.add(id);
530+
}
531+
}
532+
533+
List<String> sorted = new ArrayList<>();
534+
while (!queue.isEmpty()) {
535+
String current = queue.poll();
536+
sorted.add(current);
537+
for (var entry : dependencies.entrySet()) {
538+
if (entry.getValue().contains(current)) {
539+
int newDegree = inDegree.get(entry.getKey()) - 1;
540+
inDegree.put(entry.getKey(), newDegree);
541+
if (newDegree == 0) {
542+
queue.add(entry.getKey());
543+
}
544+
}
545+
}
546+
}
547+
548+
for (String id : allIds) {
549+
if (!sorted.contains(id)) {
550+
sorted.add(id);
551+
}
552+
}
553+
554+
return sorted;
555+
}
556+
557+
private static final Pattern SUB_VAR_PATTERN = Pattern.compile("\\$\\{([^}]+)}");
558+
559+
private void collectDependencies(JsonNode node, Set<String> allIds, Set<String> deps) {
560+
if (node == null || node.isNull() || node.isMissingNode()) {
561+
return;
562+
}
563+
if (node.isObject()) {
564+
if (node.has("Ref")) {
565+
String ref = node.get("Ref").asText();
566+
if (allIds.contains(ref)) {
567+
deps.add(ref);
568+
}
569+
return;
570+
}
571+
if (node.has("Fn::GetAtt")) {
572+
JsonNode getAtt = node.get("Fn::GetAtt");
573+
String logicalId;
574+
if (getAtt.isArray() && getAtt.size() >= 1) {
575+
logicalId = getAtt.get(0).asText();
576+
} else {
577+
logicalId = getAtt.asText().split("\\.", 2)[0];
578+
}
579+
if (allIds.contains(logicalId)) {
580+
deps.add(logicalId);
581+
}
582+
return;
583+
}
584+
if (node.has("Fn::Sub")) {
585+
collectSubDependencies(node.get("Fn::Sub"), allIds, deps);
586+
return;
587+
}
588+
node.fields().forEachRemaining(e -> collectDependencies(e.getValue(), allIds, deps));
589+
} else if (node.isArray()) {
590+
for (JsonNode item : node) {
591+
collectDependencies(item, allIds, deps);
592+
}
593+
}
594+
}
595+
596+
private void collectSubDependencies(JsonNode sub, Set<String> allIds, Set<String> deps) {
597+
String template;
598+
Set<String> explicitVars = new HashSet<>();
599+
600+
if (sub.isTextual()) {
601+
template = sub.textValue();
602+
} else if (sub.isArray() && sub.size() >= 1) {
603+
template = sub.get(0).asText();
604+
if (sub.size() >= 2 && sub.get(1).isObject()) {
605+
sub.get(1).fieldNames().forEachRemaining(explicitVars::add);
606+
collectDependencies(sub.get(1), allIds, deps);
607+
}
608+
} else {
609+
return;
610+
}
611+
612+
Matcher matcher = SUB_VAR_PATTERN.matcher(template);
613+
while (matcher.find()) {
614+
String varName = matcher.group(1);
615+
if (varName.startsWith("AWS::") || explicitVars.contains(varName)) {
616+
continue;
617+
}
618+
int dot = varName.indexOf('.');
619+
String resourcePart = dot > 0 ? varName.substring(0, dot) : varName;
620+
if (allIds.contains(resourcePart)) {
621+
deps.add(resourcePart);
622+
}
623+
}
624+
}
625+
485626
private static String key(String stackName, String region) {
486627
return region + ":" + stackName;
487628
}

src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationIntegrationTest.java

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,183 @@ void createStack_withEventBridgeRuleAutoName() {
15761576
.body("Rules.size()", equalTo(1));
15771577
}
15781578

1579+
@Test
1580+
void createStack_dependencyOrdering_refBeforeTarget() {
1581+
String template = """
1582+
{
1583+
"Resources": {
1584+
"ParamForQueue": {
1585+
"Type": "AWS::SSM::Parameter",
1586+
"Properties": {
1587+
"Name": "/dep-order/ref-queue-name",
1588+
"Type": "String",
1589+
"Value": {"Ref": "DepOrderQueue"}
1590+
}
1591+
},
1592+
"DepOrderQueue": {
1593+
"Type": "AWS::SQS::Queue",
1594+
"Properties": {
1595+
"QueueName": "dep-order-ref-queue"
1596+
}
1597+
}
1598+
}
1599+
}
1600+
""";
1601+
1602+
given()
1603+
.contentType("application/x-www-form-urlencoded")
1604+
.formParam("Action", "CreateStack")
1605+
.formParam("StackName", "dep-order-ref-stack")
1606+
.formParam("TemplateBody", template)
1607+
.when()
1608+
.post("/")
1609+
.then()
1610+
.statusCode(200)
1611+
.body(containsString("<StackId>"));
1612+
1613+
given()
1614+
.contentType("application/x-www-form-urlencoded")
1615+
.formParam("Action", "DescribeStacks")
1616+
.formParam("StackName", "dep-order-ref-stack")
1617+
.when()
1618+
.post("/")
1619+
.then()
1620+
.statusCode(200)
1621+
.body(containsString("<StackStatus>CREATE_COMPLETE</StackStatus>"));
1622+
1623+
given()
1624+
.header("X-Amz-Target", "AmazonSSM.GetParameter")
1625+
.contentType(SSM_CONTENT_TYPE)
1626+
.body("""
1627+
{"Name": "/dep-order/ref-queue-name", "WithDecryption": true}
1628+
""")
1629+
.when()
1630+
.post("/")
1631+
.then()
1632+
.statusCode(200)
1633+
.body("Parameter.Value", containsString("dep-order-ref-queue"));
1634+
}
1635+
1636+
@Test
1637+
void createStack_dependencyOrdering_getAttBeforeTarget() {
1638+
String template = """
1639+
{
1640+
"Resources": {
1641+
"ArnParam": {
1642+
"Type": "AWS::SSM::Parameter",
1643+
"Properties": {
1644+
"Name": "/dep-order/getatt-table-arn",
1645+
"Type": "String",
1646+
"Value": {"Fn::GetAtt": ["DepOrderTable", "Arn"]}
1647+
}
1648+
},
1649+
"DepOrderTable": {
1650+
"Type": "AWS::DynamoDB::Table",
1651+
"Properties": {
1652+
"TableName": "dep-order-getatt-table",
1653+
"AttributeDefinitions": [
1654+
{"AttributeName": "pk", "AttributeType": "S"}
1655+
],
1656+
"KeySchema": [
1657+
{"AttributeName": "pk", "KeyType": "HASH"}
1658+
]
1659+
}
1660+
}
1661+
}
1662+
}
1663+
""";
1664+
1665+
given()
1666+
.contentType("application/x-www-form-urlencoded")
1667+
.formParam("Action", "CreateStack")
1668+
.formParam("StackName", "dep-order-getatt-stack")
1669+
.formParam("TemplateBody", template)
1670+
.when()
1671+
.post("/")
1672+
.then()
1673+
.statusCode(200)
1674+
.body(containsString("<StackId>"));
1675+
1676+
given()
1677+
.contentType("application/x-www-form-urlencoded")
1678+
.formParam("Action", "DescribeStacks")
1679+
.formParam("StackName", "dep-order-getatt-stack")
1680+
.when()
1681+
.post("/")
1682+
.then()
1683+
.statusCode(200)
1684+
.body(containsString("<StackStatus>CREATE_COMPLETE</StackStatus>"));
1685+
1686+
given()
1687+
.header("X-Amz-Target", "AmazonSSM.GetParameter")
1688+
.contentType(SSM_CONTENT_TYPE)
1689+
.body("""
1690+
{"Name": "/dep-order/getatt-table-arn", "WithDecryption": true}
1691+
""")
1692+
.when()
1693+
.post("/")
1694+
.then()
1695+
.statusCode(200)
1696+
.body("Parameter.Value", startsWith("arn:aws:dynamodb:"));
1697+
}
1698+
1699+
@Test
1700+
void createStack_dependencyOrdering_fnSubBeforeTarget() {
1701+
String template = """
1702+
{
1703+
"Resources": {
1704+
"SubParam": {
1705+
"Type": "AWS::SSM::Parameter",
1706+
"Properties": {
1707+
"Name": "/dep-order/sub-queue-arn",
1708+
"Type": "String",
1709+
"Value": {"Fn::Sub": "arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:${DepSubQueue}"}
1710+
}
1711+
},
1712+
"DepSubQueue": {
1713+
"Type": "AWS::SQS::Queue",
1714+
"Properties": {
1715+
"QueueName": "dep-order-sub-queue"
1716+
}
1717+
}
1718+
}
1719+
}
1720+
""";
1721+
1722+
given()
1723+
.contentType("application/x-www-form-urlencoded")
1724+
.formParam("Action", "CreateStack")
1725+
.formParam("StackName", "dep-order-sub-stack")
1726+
.formParam("TemplateBody", template)
1727+
.when()
1728+
.post("/")
1729+
.then()
1730+
.statusCode(200)
1731+
.body(containsString("<StackId>"));
1732+
1733+
given()
1734+
.contentType("application/x-www-form-urlencoded")
1735+
.formParam("Action", "DescribeStacks")
1736+
.formParam("StackName", "dep-order-sub-stack")
1737+
.when()
1738+
.post("/")
1739+
.then()
1740+
.statusCode(200)
1741+
.body(containsString("<StackStatus>CREATE_COMPLETE</StackStatus>"));
1742+
1743+
given()
1744+
.header("X-Amz-Target", "AmazonSSM.GetParameter")
1745+
.contentType(SSM_CONTENT_TYPE)
1746+
.body("""
1747+
{"Name": "/dep-order/sub-queue-arn", "WithDecryption": true}
1748+
""")
1749+
.when()
1750+
.post("/")
1751+
.then()
1752+
.statusCode(200)
1753+
.body("Parameter.Value", containsString("dep-order-sub-queue"));
1754+
}
1755+
15791756
@Test
15801757
void deleteStack_withEventBridgeRule() {
15811758
String template = """

0 commit comments

Comments
 (0)