diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java index 85710990bd..404bffda2b 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationResourceProvisioner.java @@ -484,7 +484,8 @@ static String unsupportedResourceTypeMessage(String resourceType) { public StackResource provisionStandalone(String resourceType, JsonNode properties, String region, String accountId) { CloudFormationTemplateEngine engine = new CloudFormationTemplateEngine( accountId, region, "cloudcontrol", "cloudcontrol", - Map.of(), new HashMap<>(), new HashMap<>(), Map.of(), Map.of(), objectMapper, name -> null); + Map.of(), new HashMap<>(), new HashMap<>(), Map.of(), Map.of(), objectMapper, name -> null, + value -> dynamicReferences.resolveGeneralPropertyDynamicReferences(value, region)); return provision("resource", resourceType, properties, engine, region, accountId, "cloudcontrol"); } @@ -5723,7 +5724,10 @@ private String resolveOptional(JsonNode props, String name, CloudFormationTempla /** * Resolves CloudFormation dynamic references in a provisioned property value. Delegates to - * {@link CfnDynamicReferences}; the RDS master-credential paths are its only callers today. + * {@link CfnDynamicReferences}. {@code allowSsmSecure} is {@code true} only for the RDS + * master-credential properties resolved here directly; every other property value reaches + * {@link CfnDynamicReferences} through {@link CloudFormationTemplateEngine#resolveNode}, which + * disallows {@code ssm-secure} the same way the general path does. */ private String resolveDynamicReferences(String value, String region, boolean allowSsmSecure) { return dynamicReferences.resolveDynamicReferences(value, region, allowSsmSecure); diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java index 25e447ae8f..0f4ae23321 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationService.java @@ -15,6 +15,7 @@ import io.github.hectorvent.floci.services.cloudformation.model.StackEvent; import io.github.hectorvent.floci.services.cloudformation.model.StackResource; import io.github.hectorvent.floci.services.cloudformation.model.TemplateSummary; +import io.github.hectorvent.floci.services.cloudformation.provisioners.CfnDynamicReferences; import io.github.hectorvent.floci.services.cloudformation.provisioners.CfnRollback; import io.github.hectorvent.floci.services.cloudformation.provisioners.UpdateCleanupResult; import io.github.hectorvent.floci.services.s3.S3Service; @@ -65,6 +66,7 @@ public class CloudFormationService implements ResourceProvider { private final CloudFormationResourceProvisioner provisioner; private final S3Service s3Service; private final SsmService ssmService; + private final CfnDynamicReferences dynamicReferences; private final ObjectMapper objectMapper; private final EmulatorConfig config; private final RegionResolver regionResolver; @@ -81,12 +83,14 @@ public class CloudFormationService implements ResourceProvider { @Inject public CloudFormationService(CloudFormationResourceProvisioner provisioner, S3Service s3Service, - SsmService ssmService, ObjectMapper objectMapper, EmulatorConfig config, + SsmService ssmService, CfnDynamicReferences dynamicReferences, + ObjectMapper objectMapper, EmulatorConfig config, RegionResolver regionResolver, Clock clock, StorageFactory storageFactory) { this.provisioner = provisioner; this.s3Service = s3Service; this.ssmService = ssmService; + this.dynamicReferences = dynamicReferences; this.objectMapper = objectMapper; this.config = config; this.regionResolver = regionResolver; @@ -1135,7 +1139,8 @@ private void executeTemplate(Stack stack, String templateBody, Map exports.get(accountExportKey(accountId, exportKey(region, name)))); + name -> exports.get(accountExportKey(accountId, exportKey(region, name))), + value -> dynamicReferences.resolveGeneralPropertyDynamicReferences(value, region)); StackResource resource = stack.getResources().get(logicalId); StackResource previousResource = resource; @@ -1233,7 +1238,8 @@ private void executeTemplate(Stack stack, String templateBody, Map exports.get(accountExportKey(accountId, exportKey(region, name)))); + name -> exports.get(accountExportKey(accountId, exportKey(region, name))), + value -> dynamicReferences.resolveGeneralPropertyDynamicReferences(value, region)); // Resolve outputs before mutating stack/global export state, so failed updates do not // leave stale or partially registered exports behind. diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngine.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngine.java index 69d162473b..74a0438fc6 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngine.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngine.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.regex.Pattern; /** @@ -36,6 +37,7 @@ public class CloudFormationTemplateEngine { private final Map mappings; private final ObjectMapper objectMapper; private final Function importValueResolver; + private final UnaryOperator dynamicReferenceResolver; CloudFormationTemplateEngine(String accountId, String region, String stackName, String stackId, Map parameters, @@ -45,6 +47,29 @@ public class CloudFormationTemplateEngine { Map mappings, ObjectMapper objectMapper, Function importValueResolver) { + this(accountId, region, stackName, stackId, parameters, physicalIds, resourceAttributes, + conditions, mappings, objectMapper, importValueResolver, null); + } + + /** + * @param dynamicReferenceResolver resolves a value that may contain CloudFormation dynamic + * references ({@code {{resolve:ssm:...}}}, + * {@code {{resolve:secretsmanager:...}}}) against the live + * services, leaving a value with no dynamic reference syntax + * untouched. {@code null} skips this stage entirely, which + * keeps template values that only ever contain intrinsics + * (tests, Cloud Control desired-state resolution) free of the + * dependency. + */ + CloudFormationTemplateEngine(String accountId, String region, String stackName, String stackId, + Map parameters, + Map physicalIds, + Map> resourceAttributes, + Map conditions, + Map mappings, + ObjectMapper objectMapper, + Function importValueResolver, + UnaryOperator dynamicReferenceResolver) { this.accountId = accountId; this.region = region; this.stackName = stackName; @@ -56,9 +81,21 @@ public class CloudFormationTemplateEngine { this.mappings = mappings; this.objectMapper = objectMapper; this.importValueResolver = importValueResolver; + this.dynamicReferenceResolver = dynamicReferenceResolver; } + /** + * Resolves a property value, including CloudFormation dynamic reference syntax + * ({@code {{resolve:ssm:...}}}, {@code {{resolve:secretsmanager:...}}}) in the result, the same + * way {@link #resolveNode} does. This is the entry point every scalar-property resolution in + * this codebase goes through, directly or via {@link #resolveNode}, so a dynamic reference is + * substituted regardless of which of the two a caller uses. + */ public String resolve(JsonNode node) { + return resolveDynamicReferences(resolveIntrinsic(node)).asText(); + } + + private String resolveIntrinsic(JsonNode node) { if (node == null || node.isNull() || node.isMissingNode()) { return ""; } @@ -116,7 +153,10 @@ public JsonNode resolveNode(JsonNode node) { if (node == null || node.isNull() || node.isMissingNode()) { return node; } - if (node.isTextual() || node.isNumber() || node.isBoolean()) { + if (node.isTextual()) { + return resolveDynamicReferences(node.textValue()); + } + if (node.isNumber() || node.isBoolean()) { return node; } if (node.isObject()) { @@ -156,6 +196,22 @@ public JsonNode resolveNode(JsonNode node) { return node; } + /** + * Resolves a value against {@link #dynamicReferenceResolver} when it carries CloudFormation + * dynamic reference syntax ({@code {{resolve:ssm:...}}}, {@code {{resolve:secretsmanager:...}}}). + * CloudFormation substitutes these for any string property in a template, not only the RDS + * master-credential properties that first needed them (see + * #2213), so {@link #resolveNode} + * applies this to every textual result: a literal string carrying the syntax outright, and the + * text an intrinsic function (e.g. {@code Fn::Sub}) produces. + */ + private TextNode resolveDynamicReferences(String value) { + if (dynamicReferenceResolver == null || value == null || !value.contains("{{resolve:")) { + return TextNode.valueOf(value); + } + return TextNode.valueOf(dynamicReferenceResolver.apply(value)); + } + /** * Resolves a node that must be stored as a JSON document (SNS/SQS RedrivePolicy and * FilterPolicy, Step Functions definitions, IAM policy documents) to its string form. diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CfnDynamicReferences.java b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CfnDynamicReferences.java index 25477a61ac..376933043e 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CfnDynamicReferences.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudformation/provisioners/CfnDynamicReferences.java @@ -67,6 +67,34 @@ public String resolveDynamicReferences(String value, String region, boolean allo return sb.toString(); } + /** + * Resolves dynamic references in a general (non-RDS-master-credential) template value, the + * same way {@link #resolveDynamicReferences} does, except a {@code ssm-secure} reference is + * left verbatim instead of rejected. {@code ssm-secure} is valid only for + * {@code MasterUsername}/{@code MasterUserPassword}, but this general stage runs on every + * property before the RDS provisioner gets its own turn at those two with + * {@code allowSsmSecure=true}; rejecting it here would reject a value RDS is about to accept. + */ + public String resolveGeneralPropertyDynamicReferences(String value, String region) { + if (value == null || !value.contains("{{resolve:")) { + return value; + } + Matcher m = DYNAMIC_REF.matcher(value); + StringBuilder sb = new StringBuilder(); + int previousEnd = 0; + while (m.find()) { + rejectUnclosedDynamicReference(value.substring(previousEnd, m.start())); + String replacement = "ssm-secure".equals(m.group(1)) + ? m.group(0) + : resolveDynamicRef(m.group(1), m.group(2), region, false); + m.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + previousEnd = m.end(); + } + rejectUnclosedDynamicReference(value.substring(previousEnd)); + m.appendTail(sb); + return sb.toString(); + } + private String resolveDynamicRef(String service, String body, String region, boolean allowSsmSecure) { if ("secretsmanager".equals(service)) { // body = :SecretString:::. The diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationDynamicReferenceIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationDynamicReferenceIntegrationTest.java new file mode 100644 index 0000000000..1f096d6747 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationDynamicReferenceIntegrationTest.java @@ -0,0 +1,280 @@ +package io.github.hectorvent.floci.services.cloudformation; + +import io.github.hectorvent.floci.testing.RestAssuredJsonUtils; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; + +/** + * CloudFormation resolves {@code {{resolve:...}}} dynamic references for any string property in a + * template, not only the RDS master-credential properties that first needed them (issue #2213). + * These tests exercise that general path end to end through a real stack deploy, using a Lambda + * {@code Environment.Variables} entry the way the AWS documentation's own example does. + */ +@QuarkusTest +class CloudFormationDynamicReferenceIntegrationTest { + + private static final String SSM_CONTENT_TYPE = "application/x-amz-json-1.1"; + private static final String SM_CONTENT_TYPE = "application/x-amz-json-1.1"; + + @BeforeAll + static void configureRestAssured() { + RestAssuredJsonUtils.configureAwsContentTypes(); + } + + @Test + void createStack_lambdaEnvironmentVariableResolvesSsmDynamicReference() { + given() + .header("X-Amz-Target", "AmazonSSM.PutParameter") + .contentType(SSM_CONTENT_TYPE) + .body(""" + { + "Name": "/cfn-dynref/url", + "Value": "https://real.example.com", + "Type": "String", + "Overwrite": true + } + """) + .when() + .post("/") + .then() + .statusCode(200); + + String template = """ + { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": "cfn-dynref-ssm-func", + "Runtime": "nodejs20.x", + "Handler": "index.handler", + "Role": "arn:aws:iam::000000000000:role/cfn-test-lambda-role", + "Environment": { + "Variables": { + "URL": "{{resolve:ssm:/cfn-dynref/url}}" + } + } + } + } + } + } + """; + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "CreateStack") + .formParam("StackName", "cfn-dynref-ssm-stack") + .formParam("TemplateBody", template) + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("")); + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", "cfn-dynref-ssm-stack") + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("CREATE_COMPLETE")); + + given() + .when() + .get("/2015-03-31/functions/cfn-dynref-ssm-func") + .then() + .statusCode(200) + .body("Configuration.Environment.Variables.URL", equalTo("https://real.example.com")); + } + + @Test + void createStack_lambdaEnvironmentVariableResolvesSecretsManagerDynamicReference() { + given() + .header("X-Amz-Target", "secretsmanager.CreateSecret") + .contentType(SM_CONTENT_TYPE) + .body(""" + { + "Name": "cfn-dynref-secret", + "SecretString": "{\\"apiKey\\":\\"s3cr3t-value\\"}" + } + """) + .when() + .post("/") + .then() + .statusCode(200); + + String template = """ + { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": "cfn-dynref-sm-func", + "Runtime": "nodejs20.x", + "Handler": "index.handler", + "Role": "arn:aws:iam::000000000000:role/cfn-test-lambda-role", + "Environment": { + "Variables": { + "API_KEY": "{{resolve:secretsmanager:cfn-dynref-secret:SecretString:apiKey}}" + } + } + } + } + } + } + """; + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "CreateStack") + .formParam("StackName", "cfn-dynref-sm-stack") + .formParam("TemplateBody", template) + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("")); + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", "cfn-dynref-sm-stack") + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("CREATE_COMPLETE")); + + given() + .when() + .get("/2015-03-31/functions/cfn-dynref-sm-func") + .then() + .statusCode(200) + .body("Configuration.Environment.Variables.API_KEY", equalTo("s3cr3t-value")); + } + + @Test + void createStack_snsTopicNameResolvesSsmDynamicReferenceThroughResolveOptional() { + given() + .header("X-Amz-Target", "AmazonSSM.PutParameter") + .contentType(SSM_CONTENT_TYPE) + .body(""" + { + "Name": "/cfn-dynref/topic-name", + "Value": "cfn-dynref-resolved-topic", + "Type": "String", + "Overwrite": true + } + """) + .when() + .post("/") + .then() + .statusCode(200); + + String template = """ + { + "Resources": { + "MyTopic": { + "Type": "AWS::SNS::Topic", + "Properties": { + "TopicName": "{{resolve:ssm:/cfn-dynref/topic-name}}" + } + } + } + } + """; + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "CreateStack") + .formParam("StackName", "cfn-dynref-sns-stack") + .formParam("TemplateBody", template) + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("")); + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", "cfn-dynref-sns-stack") + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("CREATE_COMPLETE")); + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "ListTopics") + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("cfn-dynref-resolved-topic")) + .body(org.hamcrest.Matchers.not(containsString("{{resolve:ssm:"))); + } + + @Test + void createStack_lambdaEnvironmentVariableLeavesPlainLiteralStringUntouched() { + String template = """ + { + "Resources": { + "MyFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "FunctionName": "cfn-dynref-literal-func", + "Runtime": "nodejs20.x", + "Handler": "index.handler", + "Role": "arn:aws:iam::000000000000:role/cfn-test-lambda-role", + "Environment": { + "Variables": { + "PLAIN": "just-a-normal-value", + "BRACES": "not-a-dynamic-reference-{{example}}" + } + } + } + } + } + } + """; + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "CreateStack") + .formParam("StackName", "cfn-dynref-literal-stack") + .formParam("TemplateBody", template) + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("")); + + given() + .contentType("application/x-www-form-urlencoded") + .formParam("Action", "DescribeStacks") + .formParam("StackName", "cfn-dynref-literal-stack") + .when() + .post("/") + .then() + .statusCode(200) + .body(containsString("CREATE_COMPLETE")); + + given() + .when() + .get("/2015-03-31/functions/cfn-dynref-literal-func") + .then() + .statusCode(200) + .body("Configuration.Environment.Variables.PLAIN", equalTo("just-a-normal-value")) + .body("Configuration.Environment.Variables.BRACES", + equalTo("not-a-dynamic-reference-{{example}}")); + } +} diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationServiceRollbackTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationServiceRollbackTest.java index ef5bbecd39..ae576626f1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationServiceRollbackTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationServiceRollbackTest.java @@ -9,6 +9,7 @@ import io.github.hectorvent.floci.core.storage.StorageFactory; import io.github.hectorvent.floci.services.cloudformation.model.Stack; import io.github.hectorvent.floci.services.cloudformation.model.StackResource; +import io.github.hectorvent.floci.services.cloudformation.provisioners.CfnDynamicReferences; import io.github.hectorvent.floci.services.s3.S3Service; import io.github.hectorvent.floci.services.ssm.SsmService; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +48,7 @@ void setUp() { provisioner, mock(S3Service.class), mock(SsmService.class), + mock(CfnDynamicReferences.class), new ObjectMapper(), config, mock(RegionResolver.class), diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngineTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngineTest.java index 6d9f97eeff..2592380dce 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngineTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudformation/CloudFormationTemplateEngineTest.java @@ -10,10 +10,12 @@ import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.UnaryOperator; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; class CloudFormationTemplateEngineTest { @@ -370,4 +372,69 @@ void resolveStringListExpandsFnIfSelectingArrayWithNestedSplit() { assertEquals(java.util.List.of("subnet-default"), eFalse.resolveStringList(json("{\"Fn::If\":[\"UseCustom\",[\"subnet-prefix\",{\"Fn::Split\":[\",\",{\"Fn::ImportValue\":\"Subnets\"}]}],[\"subnet-default\"]]}"))); } + + /** + * Reproduces #2213: a Lambda {@code Environment.Variables} entry (or any other plain string + * template value) carrying {@code {{resolve:...}}} syntax reached the deployed resource as the + * literal text because {@code resolveNode}, the general-purpose path every non-RDS property goes + * through, had no dynamic-reference stage. + */ + @Test + void resolveNodeResolvesDynamicReferenceInPlainStringValue() { + UnaryOperator resolver = value -> { + assertEquals("{{resolve:ssm:/demo/url}}", value); + return "https://real.example.com"; + }; + CloudFormationTemplateEngine e = new CloudFormationTemplateEngine("000000000000", + "us-east-1", "my-stack", "stack/id", Map.of(), Map.of(), Map.of(), Map.of(), + Map.of(), mapper, (Function) name -> null, resolver); + + assertEquals("https://real.example.com", + e.resolveNode(json("\"{{resolve:ssm:/demo/url}}\"")).asText()); + } + + /** + * The same dynamic-reference stage applies to the text an intrinsic function produces, since a + * literal {@code {{resolve:...}}} embedded in an {@code Fn::Sub} template survives substitution + * untouched and reaches resolveNode's final string. + */ + @Test + void resolveNodeResolvesDynamicReferenceProducedByIntrinsic() { + UnaryOperator resolver = value -> { + assertEquals("prefix-{{resolve:ssm:/demo/url}}", value); + return "prefix-https://real.example.com"; + }; + CloudFormationTemplateEngine e = new CloudFormationTemplateEngine("000000000000", + "us-east-1", "my-stack", "stack/id", Map.of(), Map.of(), Map.of(), Map.of(), + Map.of(), mapper, (Function) name -> null, resolver); + + assertEquals("prefix-https://real.example.com", e.resolveNode( + json("{\"Fn::Sub\": \"prefix-{{resolve:ssm:/demo/url}}\"}")).asText()); + } + + /** + * A plain literal with no dynamic reference syntax must never reach the resolver: it is left + * exactly as written, and a resolver that throws proves it was not invoked. + */ + @Test + void resolveNodeLeavesPlainLiteralStringUntouched() { + UnaryOperator resolver = value -> fail("dynamic reference resolver must not be " + + "invoked for a value with no {{resolve:...}} syntax: " + value); + CloudFormationTemplateEngine e = new CloudFormationTemplateEngine("000000000000", + "us-east-1", "my-stack", "stack/id", Map.of(), Map.of(), Map.of(), Map.of(), + Map.of(), mapper, (Function) name -> null, resolver); + + assertEquals("just-a-normal-value", e.resolveNode(json("\"just-a-normal-value\"")).asText()); + } + + /** + * With no dynamic-reference resolver configured (the 11-argument constructor every other test + * in this class uses), resolveNode leaves {@code {{resolve:...}}} syntax exactly as written + * instead of failing: callers that never need dynamic references stay decoupled from them. + */ + @Test + void resolveNodeWithNoResolverLeavesDynamicReferenceSyntaxVerbatim() { + assertEquals("{{resolve:ssm:/demo/url}}", + engine().resolveNode(json("\"{{resolve:ssm:/demo/url}}\"")).asText()); + } }