stratospheric-dev / cdk-constructs

Some CDK constructs to get started with deploying to AWS.
Apache License 2.0
35 stars 21 forks source link

Template format error using Network and Service constructs #630

Closed ryanmcfall closed 1 month ago

ryanmcfall commented 1 month ago

I'm using modified versions of the code used in the Stratospheric book to deploy my application. I have two stacks:

My application deploys correctly if I don't set up an SSL certificate and provide it to the Network construct via the NetworkInputParameters instance passed to the Network construct. When I do, I get an error synthesizing:

Template format error: Unresolved dependencies [ParkHopeVPCloadbalancerhttpsListener8FA62E8D]. Cannot reference resources in the Conditions block of the template

I have confirmed the value of the ARN for the certificate is valid. The relevant portion of the app code that creates the ApplicationStack looks like this:

NetworkInputParameters networkInputParameters = 
  new NetworkInputParameters().withSslCertificateArn(sslCertificateARN);

ApplicationStack applicationStack = new ApplicationStack(app, "ParkHopeApplicationStack", awsEnvironment, applicationEnvironment, networkInputParameters, dockerImageTag, 1, slateUserName, slatePassword);

Here's ApplicationStack.java, with the most relevant methods being createVPC and createService:

package institute.hopesoftware.parkhope.infrastructure;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import dev.stratospheric.cdk.ApplicationEnvironment;
import dev.stratospheric.cdk.Network;
import dev.stratospheric.cdk.Network.NetworkInputParameters;
import dev.stratospheric.cdk.Service;
import dev.stratospheric.cdk.Service.DockerImageSource;
import dev.stratospheric.cdk.Service.ServiceInputParameters;
import software.amazon.awscdk.Environment;
import software.amazon.awscdk.RemovalPolicy;
import software.amazon.awscdk.SecretValue;
import software.amazon.awscdk.Stack;
import software.amazon.awscdk.StackProps;
import software.amazon.awscdk.services.cognito.AccountRecovery;
import software.amazon.awscdk.services.cognito.AttributeMapping;
import software.amazon.awscdk.services.cognito.AutoVerifiedAttrs;
import software.amazon.awscdk.services.cognito.CognitoDomainOptions;
import software.amazon.awscdk.services.cognito.Mfa;
import software.amazon.awscdk.services.cognito.OAuthFlows;
import software.amazon.awscdk.services.cognito.OAuthScope;
import software.amazon.awscdk.services.cognito.OAuthSettings;
import software.amazon.awscdk.services.cognito.ProviderAttribute;
import software.amazon.awscdk.services.cognito.SignInAliases;
import software.amazon.awscdk.services.cognito.UserPool;
import software.amazon.awscdk.services.cognito.UserPoolClient;
import software.amazon.awscdk.services.cognito.UserPoolClientIdentityProvider;
import software.amazon.awscdk.services.cognito.UserPoolDomainOptions;
import software.amazon.awscdk.services.cognito.UserPoolIdentityProviderGoogle;
import software.amazon.awscdk.services.ec2.CfnSecurityGroup;
import software.amazon.awscdk.services.ec2.ISubnet;
import software.amazon.awscdk.services.rds.CfnDBInstance;
import software.amazon.awscdk.services.rds.CfnDBSubnetGroup;
import software.amazon.awscdk.services.secretsmanager.CfnSecretTargetAttachment;
import software.amazon.awscdk.services.secretsmanager.ISecret;
import software.amazon.awscdk.services.secretsmanager.Secret;
import software.amazon.awscdk.services.secretsmanager.SecretStringGenerator;
import software.constructs.Construct;

public class ApplicationStack extends Stack {

    private UserPool userPool;
    private UserPoolClient userPoolClient;
    private Environment awsEnvironment;
    private ApplicationEnvironment applicationEnvironment;
    private NetworkInputParameters networkInputParameters;
    private Construct scope;
    private Network network;

    private Service service;
    private CfnDBInstance dbInstance;
    private ISecret databaseSecret;
    private CfnSecurityGroup databaseSecurityGroup;    

    public ApplicationStack(
        final Construct scope, final String id,
        final Environment awsEnvironment,
        final ApplicationEnvironment applicationEnvironment,
        NetworkInputParameters networkInputParameters, String dockerImageTag, int desiredInstances, String slateUsername, String slatePassword) {

        super(scope, id, StackProps.builder()
            .stackName(applicationEnvironment
            .prefix("Application"))
            .env(awsEnvironment).build()
        );

        this.scope = scope;
        this.applicationEnvironment = applicationEnvironment;
        this.awsEnvironment = awsEnvironment;
        this.networkInputParameters = networkInputParameters;

        setupCognito();
        network = createVpc();

        createPostgresDatabase();
        createService(dockerImageTag, desiredInstances, slateUsername, slatePassword);
    }

    public String getLoadBalancerDnsName() {
        return network.getOutputParameters().getLoadBalancerDnsName();
    }

    private void createService (String dockerImageTag, int desiredInstances, String slateUsername, String slatePassword) {

        Map<String, String> vars = new HashMap<String, String>(Map.of(
            "SLATE_USERNAME", slateUsername, 
            "SLATE_PASSWORD", slatePassword
        ));

        String jdbcUrl = String.format("jdbc:postgresql://%s:%s/%s",
        dbInstance.getAttrEndpointAddress(),
        dbInstance.getAttrEndpointPort(),
        sanitizeDbParameterName(applicationEnvironment.prefix("database")));
        vars.put("SPRING_DATASOURCE_URL", jdbcUrl);

        String dbUserName = databaseSecret.secretValueFromJson("username").unsafeUnwrap();
        vars.put("SPRING_DATASOURCE_USERNAME", dbUserName);

        String dbPassword = databaseSecret.secretValueFromJson("password").unsafeUnwrap();
        vars.put("SPRING_DATASOURCE_PASSWORD", dbPassword);  

        DockerImageSource dockerImageSource = new DockerImageSource(applicationEnvironment.getApplicationName(), dockerImageTag);

        List<String> securityGroupIdsToGrantIngressFromEcs = Arrays.asList(
             databaseSecurityGroup.getAttrGroupId()       
        );

        ServiceInputParameters serviceInputParameters = new ServiceInputParameters(dockerImageSource, securityGroupIdsToGrantIngressFromEcs, vars)
            .withHealthCheckPath("/actuator/health")
            .withDesiredInstances(desiredInstances);

        service = new Service(this, "ECSService", awsEnvironment, applicationEnvironment,
                       serviceInputParameters, network.getOutputParameters());
        service.getNode().addDependency(network);
        service.getNode().addDependency(dbInstance);
    }

    private void setupCognito() {
            this.userPool = UserPool.Builder.create(this, "userPool")
                            .userPoolName("park-hope-pool")
                            .selfSignUpEnabled(true)
                            .signInAliases(SignInAliases.builder().email(true).build())
                            .autoVerify(AutoVerifiedAttrs.builder().email(true).build())
                            .mfa(Mfa.OFF)
                            .accountRecovery(AccountRecovery.EMAIL_ONLY)
                            .removalPolicy(RemovalPolicy.DESTROY)
                            .build();

            UserPoolDomainOptions options = UserPoolDomainOptions.builder()
                            .cognitoDomain(CognitoDomainOptions.builder().domainPrefix("parkhope").build())
                            .build();
            userPool.addDomain("parkhope", options);

            AttributeMapping attributeMapping = AttributeMapping.builder()
                            .email(ProviderAttribute.GOOGLE_EMAIL)
                            .familyName(ProviderAttribute.GOOGLE_FAMILY_NAME)
                            .givenName(ProviderAttribute.GOOGLE_GIVEN_NAME)
                            .profilePicture(ProviderAttribute.GOOGLE_PICTURE)
                            .build();

            UserPoolIdentityProviderGoogle provider = UserPoolIdentityProviderGoogle.Builder
                            .create(this, "UserPoolIdentityProviderGoogle")
                            .userPool(userPool)
                            .clientId("427616320048-onrbei8rca7qb25re38bapn6lmo3e9jv.apps.googleusercontent.com")
                            .clientSecretValue(SecretValue.secretsManager("google/login/clientSecret"))
                            .attributeMapping(attributeMapping)
                            .scopes(Arrays.asList("email", "profile", "phone", "openid"))
                            .build();

            List<OAuthScope> oAuthScopes = Arrays.asList(
                            OAuthScope.COGNITO_ADMIN, OAuthScope.EMAIL, OAuthScope.PROFILE);

            List<String> callbackUrls = Arrays.asList("http://localhost:3000/", "myapp://callback");
            List<String> logoutUrls = Arrays.asList("http://localhost:3000/", "myapp://logout");

            this.userPoolClient = UserPoolClient.Builder.create(this, "userPoolClient")
                            .userPoolClientName("park-hope-client")
                            .generateSecret(false)
                            .userPool(this.userPool)
                            .oAuth(OAuthSettings.builder()
                                            .flows(OAuthFlows.builder().authorizationCodeGrant(true).build())
                                            .scopes(oAuthScopes)
                                            .callbackUrls(callbackUrls)
                                            .logoutUrls(logoutUrls)
                                            .build())
                            .supportedIdentityProviders(
                                            Arrays.asList(UserPoolClientIdentityProvider.COGNITO,
                                                            UserPoolClientIdentityProvider.GOOGLE))
                            .build();

            this.userPoolClient.getNode().addDependency(provider);
    }

    private Network createVpc() {
            return new Network(this, "ParkHopeVPC", awsEnvironment,
                    applicationEnvironment.getEnvironmentName(), networkInputParameters);                
    }

    private String sanitizeDbParameterName(String dbParameterName) {
            return dbParameterName
                // db name must have only alphanumerical characters
                .replaceAll("[^a-zA-Z0-9]", "")
                // db name must start with a letter
                .replaceAll("^[0-9]", "a");
    }

    private void createPostgresDatabase() {
        //  This code is based on the Stratospheric PostgresDatabase construct
        //  https://github.com/stratospheric-dev/cdk-constructs/blob/main/src/main/java/dev/stratospheric/cdk/PostgresDatabase.java
        String username = sanitizeDbParameterName(applicationEnvironment.prefix("dbUser"));

        databaseSecurityGroup = CfnSecurityGroup.Builder.create(this, "databaseSecurityGroup")
            .vpcId(network.getVpc().getVpcId())
            .groupDescription("Security Group for the database instance")
            .groupName(applicationEnvironment.prefix("dbSecurityGroup"))
            .build();

        // This will generate a JSON object with the keys "username" and "password".
        databaseSecret = Secret.Builder.create(this, "databaseSecret")
            .secretName(applicationEnvironment.prefix("DatabaseSecret"))
            .description("Credentials to the RDS instance")
            .generateSecretString(SecretStringGenerator.builder()
                .secretStringTemplate(String.format("{\"username\": \"%s\"}", username))
                .generateStringKey("password")
                .passwordLength(32)
                .excludeCharacters("@/\\\" ").build())
            .build();

        List<ISubnet> subnets = network.getVpc().getIsolatedSubnets();
        List<String> subnetIds = subnets.stream().map(addr -> addr.getSubnetId()).collect(Collectors.toList());
        CfnDBSubnetGroup subnetGroup = CfnDBSubnetGroup.Builder.create(this, "dbSubnetGroup")
            .dbSubnetGroupDescription("Subnet group for the RDS instance")
            .dbSubnetGroupName(applicationEnvironment.prefix("dbSubnetGroup"))
            .subnetIds(subnetIds)
            .build();

        String postgresVersion = "12.17";
        String dbInstanceClass = "db.t3.micro";
        String allocatedStorage = "20";

        dbInstance = CfnDBInstance.Builder.create(this, "postgresInstance")
            .dbInstanceIdentifier(applicationEnvironment.prefix("database"))
            .allocatedStorage(String.valueOf(allocatedStorage))
            .availabilityZone(network.getVpc().getAvailabilityZones().get(0))
            .dbInstanceClass(dbInstanceClass)
            .dbName(sanitizeDbParameterName(applicationEnvironment.prefix("database")))
            .dbSubnetGroupName(subnetGroup.getDbSubnetGroupName())
            .engine("postgres")
            .engineVersion(postgresVersion)
            .masterUsername(username)
            .masterUserPassword(databaseSecret.secretValueFromJson("password").unsafeUnwrap())
            .publiclyAccessible(false)
            .vpcSecurityGroups(Collections.singletonList(databaseSecurityGroup.getAttrGroupId()))
            .build();

        dbInstance.getNode().addDependency(subnetGroup);

        CfnSecretTargetAttachment.Builder.create(this, "secretTargetAttachment")
            .secretId(databaseSecret.getSecretArn())
            .targetId(dbInstance.getRef())
            .targetType("AWS::RDS::DBInstance")
            .build();   

        dbInstance.getNode().addDependency(network);
    }
}
ryanmcfall commented 1 month ago

Here's the generated Cloud Formation template in case that is helpful.

ParkHopeApplicationStack.template.json

BjoernKW commented 1 month ago

@ryanmcfall From a first glimpse I can't see anything wrong with the generated template. The HttpsListener is created and referenced correctly. Hence, it should work.

Maybe, @thombergs would like to chime in?

ryanmcfall commented 1 month ago

This definitely doesn't work.

I took the generated template.json file from cdk.out, removed the references to httpsListener8FA62E8D where it appeared, uploaded the template to the AWS CloudFormation web interface on aws.amazon.com, and created a new stack using that template. This worked perfectly.

So there is something about the generated template that AWS calls an error.