Repository: aws/amazon-ecs-cli-v2
Pull request #426 (2019-11-20 03:05:38)

Set up resources in env/project stack for DNS Delegation
This pipes through the DNS name of the project to the env
templates, and wires in the custom resources.

TODO: Add the integration tests for this - but the
existing, non DNS integ tests are running fine.

I manually ran the env setup for a DNS project. 

<!-- Provide summary of changes -->

<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.



Patch:
diff --git a/internal/pkg/cli/env_init.go b/internal/pkg/cli/env_init.go
index 7a58882b..c3fcd5f4 100644
--- a/internal/pkg/cli/env_init.go
+++ b/internal/pkg/cli/env_init.go
@@ -100,6 +100,7 @@ func (opts *InitEnvOpts) Execute() error {
 		Prod:                     opts.IsProduction,
 		PublicLoadBalancer:       true, // TODO: configure this based on user input or application Type needs?
 		ToolsAccountPrincipalARN: caller.RootUserARN,
+		ProjectDNSName:           project.Domain,
 	}
 
 	if project.RequiresDNSDelegation() {
diff --git a/internal/pkg/deploy/cloudformation/stack/env.go b/internal/pkg/deploy/cloudformation/stack/env.go
index 6981d154..8c0c2ff9 100644
--- a/internal/pkg/deploy/cloudformation/stack/env.go
+++ b/internal/pkg/deploy/cloudformation/stack/env.go
@@ -1,8 +1,10 @@
 package stack
 
 import (
+	"bytes"
 	"fmt"
 	"strconv"
+	"text/template"
 
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy"
@@ -22,15 +24,19 @@ type EnvStackConfig struct {
 
 const (
 	// EnvTemplatePath is the path where the cloudformation for the environment is written.
-	EnvTemplatePath = "environment/cf.yml"
+	EnvTemplatePath           = "environment/cf.yml"
+	acmValidationTemplatePath = "custom-resources/dns-cert-validator.js"
+	dnsDelegationTemplatePath = "custom-resources/dns-delegation.js"
 )
 
 // Parameter keys.
 const (
-	envParamIncludeLBKey             = "IncludePublicLoadBalancer"
-	envParamProjectNameKey           = "ProjectName"
-	envParamEnvNameKey               = "EnvironmentName"
-	envParamToolsAccountPrincipalKey = "ToolsAccountPrincipalARN"
+	envParamIncludeLBKey                = "IncludePublicLoadBalancer"
+	envParamProjectNameKey              = "ProjectName"
+	envParamEnvNameKey                  = "EnvironmentName"
+	envParamToolsAccountPrincipalKey    = "ToolsAccountPrincipalARN"
+	envParamProjectDNSKey               = "ProjectDNSName"
+	envParamProjectDNSDelegationRoleKey = "ProjectDNSDelegationRole"
 )
 
 // Output keys.
@@ -49,11 +55,41 @@ func NewEnvStackConfig(input *deploy.CreateEnvironmentInput, box packd.Box) *Env
 
 // Template returns the environment CloudFormation template.
 func (e *EnvStackConfig) Template() (string, error) {
-	template, err := e.box.FindString(EnvTemplatePath)
+	environmentTemplate, err := e.box.FindString(EnvTemplatePath)
 	if err != nil {
 		return "", &ErrTemplateNotFound{templateLocation: EnvTemplatePath, parentErr: err}
 	}
-	return template, nil
+
+	acmValidator, err := e.box.FindString(acmValidationTemplatePath)
+	if err != nil {
+		return "", &ErrTemplateNotFound{templateLocation: acmValidationTemplatePath, parentErr: err}
+	}
+
+	dnsDelegator, err := e.box.FindString(dnsDelegationTemplatePath)
+	if err != nil {
+		return "", &ErrTemplateNotFound{templateLocation: dnsDelegationTemplatePath, parentErr: err}
+	}
+
+	templ, err := template.New("environmenttemplates").Parse(environmentTemplate)
+
+	if err != nil {
+		return "", err
+	}
+
+	templateData := struct {
+		DNSDelegationLambda string
+		ACMValidationLambda string
+	}{
+		dnsDelegator,
+		acmValidator,
+	}
+
+	var buf bytes.Buffer
+	if err := templ.Execute(&buf, templateData); err != nil {
+		return "", err
+	}
+
+	return string(buf.Bytes()), nil
 }
 
 // Parameters returns the parameters to be passed into a environment CloudFormation template.
@@ -75,6 +111,14 @@ func (e *EnvStackConfig) Parameters() []*cloudformation.Parameter {
 			ParameterKey:   aws.String(envParamToolsAccountPrincipalKey),
 			ParameterValue: aws.String(e.ToolsAccountPrincipalARN),
 		},
+		{
+			ParameterKey:   aws.String(envParamProjectDNSKey),
+			ParameterValue: aws.String(e.ProjectDNSName),
+		},
+		{
+			ParameterKey:   aws.String(envParamProjectDNSDelegationRoleKey),
+			ParameterValue: aws.String(e.dnsDelegationRole()),
+		},
 	}
 }
 
@@ -92,6 +136,18 @@ func (e *EnvStackConfig) Tags() []*cloudformation.Tag {
 	}
 }
 
+func (e *EnvStackConfig) dnsDelegationRole() string {
+	if e.ToolsAccountPrincipalARN == "" || e.ProjectDNSName == "" {
+		return ""
+	}
+
+	projectRole, err := arn.Parse(e.ToolsAccountPrincipalARN)
+	if err != nil {
+		return ""
+	}
+	return fmt.Sprintf("arn:aws:iam::%s:role/%s", projectRole.AccountID, dnsDelegationRoleName(e.Project))
+}
+
 // StackName returns the name of the CloudFormation stack (based on the project and env names).
 func (e *EnvStackConfig) StackName() string {
 	return fmt.Sprintf("%s-%s", e.Project, e.Name)
diff --git a/internal/pkg/deploy/cloudformation/stack/project.go b/internal/pkg/deploy/cloudformation/stack/project.go
index 86dc5fa0..4b495a5f 100644
--- a/internal/pkg/deploy/cloudformation/stack/project.go
+++ b/internal/pkg/deploy/cloudformation/stack/project.go
@@ -43,16 +43,18 @@ type ProjectStackConfig struct {
 }
 
 const (
-	projectTemplatePath            = "project/project.yml"
-	projectResourcesTemplatePath   = "project/cf.yml"
-	projectAdminRoleParamName      = "AdminRoleName"
-	projectExecutionRoleParamName  = "ExecutionRoleName"
-	projectOutputKMSKey            = "KMSKeyARN"
-	projectOutputS3Bucket          = "PipelineBucket"
-	projectOutputECRRepoPrefix     = "ECRRepo"
-	projectDNSDelegatedAccountsKey = "ProjectDNSDelegatedAccounts"
-	projectDomainNameKey           = "ProjectDomainName"
-	projectNameKey                 = "ProjectName"
+	projectTemplatePath               = "project/project.yml"
+	projectResourcesTemplatePath      = "project/cf.yml"
+	projectAdminRoleParamName         = "AdminRoleName"
+	projectExecutionRoleParamName     = "ExecutionRoleName"
+	projectDNSDelegationRoleParamName = "DNSDelegationRoleName"
+	projectOutputKMSKey               = "KMSKeyARN"
+	projectOutputS3Bucket             = "PipelineBucket"
+	projectOutputECRRepoPrefix        = "ECRRepo"
+	projectDNSDelegatedAccountsKey    = "ProjectDNSDelegatedAccounts"
+	projectDomainNameKey              = "ProjectDomainName"
+	projectNameKey                    = "ProjectName"
+	projectDNSDelegationRoleName      = "DNSDelegationRole"
 )
 
 // ProjectConfigFrom takes a template file and extracts the metadata block,
@@ -137,6 +139,10 @@ func (c *ProjectStackConfig) Parameters() []*cloudformation.Parameter {
 			ParameterKey:   aws.String(projectNameKey),
 			ParameterValue: aws.String(c.Project),
 		},
+		{
+			ParameterKey:   aws.String(projectDNSDelegationRoleParamName),
+			ParameterValue: aws.String(dnsDelegationRoleName(c.Project)),
+		},
 	}
 }
 
@@ -251,3 +257,7 @@ func DNSDelegatedAccountsForStack(stack *cloudformation.Stack) []string {
 
 	return []string{}
 }
+
+func dnsDelegationRoleName(projectName string) string {
+	return fmt.Sprintf("%s-%s", projectName, projectDNSDelegationRoleName)
+}
diff --git a/internal/pkg/deploy/env.go b/internal/pkg/deploy/env.go
index ff2b2858..8c79eca3 100644
--- a/internal/pkg/deploy/env.go
+++ b/internal/pkg/deploy/env.go
@@ -16,6 +16,7 @@ type CreateEnvironmentInput struct {
 	Prod                     bool   // Whether or not this environment is a production environment.
 	PublicLoadBalancer       bool   // Whether or not this environment should contain a shared public load balancer between applications.
 	ToolsAccountPrincipalARN string // The Principal ARN of the tools account.
+	ProjectDNSName           string // The DNS name of this project, if it exists
 }
 
 // CreateEnvironmentResponse holds the created environment on successful deployment.
diff --git a/templates/environment/cf.yml b/templates/environment/cf.yml
index 768a7285..6df11553 100644
--- a/templates/environment/cf.yml
+++ b/templates/environment/cf.yml
@@ -38,9 +38,23 @@ Parameters:
   ToolsAccountPrincipalARN:
     Type: String
 
+  ProjectDNSName:
+    Type: String
+    Default: ""
+
+  ProjectDNSDelegationRole:
+    Type: String
+    Default: ""
+
 Conditions:
   CreatePublicLoadBalancer:
     Fn::Equals: [ !Ref IncludePublicLoadBalancer, true ]
+  DelegateDNS:
+    !Not [!Equals [ !Ref ProjectDNSName, "" ]]
+  ExportHTTPSListener: !And
+    - !Condition DelegateDNS
+    - !Condition CreatePublicLoadBalancer
+
 
 Resources:
   VPC:
@@ -192,6 +206,11 @@ Resources:
           FromPort: 80
           IpProtocol: tcp
           ToPort: 80
+        - CidrIp: 0.0.0.0/0
+          Description: Allow from anyone on port 443
+          FromPort: 443
+          IpProtocol: tcp
+          ToPort: 443
       VpcId: !Ref VPC
 
   PublicLoadBalancer:
@@ -227,7 +246,21 @@ Resources:
       LoadBalancerArn: !Ref PublicLoadBalancer
       Port: 80
       Protocol: HTTP
-  
+
+  HTTPSListener:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    DependsOn: HTTPSCert
+    Condition: DelegateDNS
+    Properties:
+      Certificates:
+        - CertificateArn: !Ref HTTPSCert
+      DefaultActions:
+        - TargetGroupArn: !Ref DefaultHTTPTargetGroup
+          Type: forward
+      LoadBalancerArn: !Ref PublicLoadBalancer
+      Port: 443
+      Protocol: HTTPS
+
   CloudformationExecutionRole:
     Type: AWS::IAM::Role
     Properties:
@@ -250,13 +283,13 @@ Resources:
           PolicyDocument:
             Version: '2012-10-17'
             Statement:
-            - 
+            -
               Effect: Allow
               NotAction:
                 - 'organizations:*'
                 - 'account:*'
               Resource: '*'
-            - 
+            -
               Effect: Allow
               Action:
                 - 'organizations:DescribeOrganization'
@@ -437,6 +470,109 @@ Resources:
             ]
             Resource: "*"
 
+  # DNS Delegation Resources
+  CertificateValidationFunction:
+    Type: AWS::Lambda::Function
+    Condition: DelegateDNS
+    Properties:
+      Code:
+        ZipFile: |
+          {{.ACMValidationLambda}}
+      Handler: "index.certificateRequestHandler"
+      Timeout: 600
+      MemorySize: 512
+      Role: !GetAtt 'CustomResourceRole.Arn'
+      Runtime: nodejs10.x
+
+  DNSDelegationFunction:
+    Type: AWS::Lambda::Function
+    Condition: DelegateDNS
+    Properties:
+      Code:
+        ZipFile: |
+          {{.DNSDelegationLambda}}
+      Handler: "index.domainDelegationHandler"
+      Timeout: 600
+      MemorySize: 512
+      Role: !GetAtt 'CustomResourceRole.Arn'
+      Runtime: nodejs10.x
+
+  EnvironmentHostedZone:
+    Type: "AWS::Route53::HostedZone"
+    Condition: DelegateDNS
+    Properties:
+      HostedZoneConfig:
+        Comment: !Sub "HostedZone for environment ${EnvironmentName} - ${EnvironmentName}.${ProjectName}.${ProjectDNSName}"
+      Name: !Sub ${EnvironmentName}.${ProjectName}.${ProjectDNSName}
+
+  CustomResourceRole:
+    Type: AWS::IAM::Role
+    Condition: DelegateDNS
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+          -
+            Effect: Allow
+            Principal:
+              Service:
+                - lambda.amazonaws.com
+            Action:
+              - sts:AssumeRole
+      Path: /
+      Policies:
+        - PolicyName: "DNSandACMAccess"
+          PolicyDocument:
+            Version: '2012-10-17'
+            Statement:
+              - Effect: Allow
+                Action:
+                  - "acm:ListCertificates"
+                  - "acm:RequestCertificate"
+                  - "acm:DescribeCertificate"
+                  - "acm:GetCertificate"
+                  - "acm:DeleteCertificate"
+                  - "acm:AddTagsToCertificate"
+                  - "route53:ListHostedZonesByName"
+                  - "sts:AssumeRole"
+                  - "logs:*"
+                  - "route53:ChangeResourceRecordSets"
+                  - "route53:ListResourceRecordSets"
+                  - "route53:Get*"
+                  - "route53:Describe*"
+                Resource:
+                  - "*"
+  # Adds records for this environment's hostedzone
+  # into the project's hostedzone. This lets this
+  # environment own the DNS of the it's subdomain.
+  DelegateDNSAction:
+    Condition: DelegateDNS
+    Type: Custom::DNSDelegationFunction
+    DependsOn:
+    - DNSDelegationFunction
+    - EnvironmentHostedZone
+    Properties:
+      ServiceToken: !GetAtt DNSDelegationFunction.Arn
+      DomainName: !Sub ${ProjectName}.${ProjectDNSName}
+      SubdomainName: !Sub ${EnvironmentName}.${ProjectName}.${ProjectDNSName}
+      NameServers: !GetAtt EnvironmentHostedZone.NameServers
+      RootDNSRole: !Ref ProjectDNSDelegationRole
+
+  HTTPSCert:
+    Condition: DelegateDNS
+    Type: Custom::CertificateValidationFunction
+    DependsOn:
+    - CertificateValidationFunction
+    - EnvironmentHostedZone
+    - DelegateDNSAction
+    Properties:
+      ServiceToken: !GetAtt CertificateValidationFunction.Arn
+      DomainName: !Sub ${EnvironmentName}.${ProjectName}.${ProjectDNSName}
+      HostedZoneId: !Ref EnvironmentHostedZone
+      Region: !Ref AWS::Region
+      SubjectAlternativeNames:
+      - !Sub "*.${EnvironmentName}.${ProjectName}.${ProjectDNSName}"
+
 Outputs:
   VpcId:
     Value: !Ref VPC
@@ -458,6 +594,8 @@ Outputs:
   PublicLoadBalancerDNSName:
     Condition: CreatePublicLoadBalancer
     Value: !GetAtt PublicLoadBalancer.DNSName
+    Export:
+      Name: !Sub ${AWS::StackName}-PublicLoadBalancerDNS
 
   PublicLoadBalancerArn:
     Condition: CreatePublicLoadBalancer
@@ -471,12 +609,24 @@ Outputs:
     Export:
       Name: !Sub ${AWS::StackName}-PublicLoadBalancerSecurityGroupId
 
+  PublicLoadBalancerHostedZone:
+    Condition: CreatePublicLoadBalancer
+    Value: !GetAtt PublicLoadBalancer.CanonicalHostedZoneID
+    Export:
+      Name: !Sub ${AWS::StackName}-CanonicalHostedZoneID
+
   HTTPListenerArn:
     Condition: CreatePublicLoadBalancer
     Value: !Ref HTTPListener
     Export:
       Name: !Sub ${AWS::StackName}-HTTPListenerArn
 
+  HTTPSListenerArn:
+    Condition: ExportHTTPSListener
+    Value: !Ref HTTPSListener
+    Export:
+      Name: !Sub ${AWS::StackName}-HTTPSListenerArn
+
   DefaultHTTPTargetGroupArn:
     Condition: CreatePublicLoadBalancer
     Value: !Ref DefaultHTTPTargetGroup
@@ -493,8 +643,23 @@ Outputs:
     Description: The role to be assumed by the ecs-cli to manage environments.
     Export:
       Name: !Sub ${AWS::StackName}-EnvironmentManagerRoleARN
+
   CFNExecutionRoleARN:
     Value: !GetAtt CloudformationExecutionRole.Arn
     Description: The role to be assumed by the Cloudformation service when it deploys application infrastructure.
     Export:
-      Name: !Sub ${AWS::StackName}-CFNExecutionRoleARN
\ No newline at end of file
+      Name: !Sub ${AWS::StackName}-CFNExecutionRoleARN
+
+  EnvironmentHostedZone:
+    Condition: DelegateDNS
+    Value: !Ref EnvironmentHostedZone
+    Description: The HostedZone for this environment's private DNS.
+    Export:
+      Name: !Sub ${AWS::StackName}-HostedZone
+
+  EnvironmentSubdomain:
+    Condition: DelegateDNS
+    Value: !Sub ${EnvironmentName}.${ProjectName}.${ProjectDNSName}
+    Description: The domain name of this environment.
+    Export:
+      Name: !Sub ${AWS::StackName}-SubDomain
diff --git a/templates/project/project.yml b/templates/project/project.yml
index a1ced82b..06d39939 100644
--- a/templates/project/project.yml
+++ b/templates/project/project.yml
@@ -7,6 +7,9 @@ Parameters:
     Type: String
   ExecutionRoleName:
     Type: String
+  DNSDelegationRoleName:
+    Type: String
+    Default: ""
   ProjectDNSDelegatedAccounts:
     Type: CommaDelimitedList
     Default: ""
@@ -99,6 +102,7 @@ Resources:
     Type: AWS::IAM::Role
     Condition: DelegateDNS
     Properties:
+      RoleName: !Ref DNSDelegationRoleName
       AssumeRolePolicyDocument:
         Version: 2012-10-17
         Statement:


Test patch:
diff --git a/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go b/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
index 179f1f6a..62d89be7 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
@@ -380,7 +380,7 @@ func Test_Environment_Deployment_Integration(t *testing.T) {
 
 		// Deploy the environment and wait for it to be complete
 		require.NoError(t, deployer.DeployEnvironment(&environmentToDeploy))
-		// Make sure the environment was deployed succesfully
+		// Make sure the environment was deployed successfully
 
 		_, responses := deployer.StreamEnvironmentCreation(&environmentToDeploy)
 		resp := <-responses
@@ -480,6 +480,11 @@ func Test_Environment_Deployment_Integration(t *testing.T) {
 					output.OutputValue,
 					"PublicLoadBalancerDNSName value should not be nil")
 			},
+			"PublicLoadBalancerHostedZone": func(output *awsCF.Output) {
+				require.NotNil(t,
+					output.OutputValue,
+					"PublicLoadBalancerHostedZone value should not be nil")
+			},
 			"HTTPListenerArn": func(output *awsCF.Output) {
 				require.Equal(t,
 					fmt.Sprintf("%s-HTTPListenerArn", envStackName),
diff --git a/internal/pkg/deploy/cloudformation/project_test.go b/internal/pkg/deploy/cloudformation/project_test.go
index de1bd474..8c040a18 100644
--- a/internal/pkg/deploy/cloudformation/project_test.go
+++ b/internal/pkg/deploy/cloudformation/project_test.go
@@ -1031,7 +1031,7 @@ func TestDelegateDNSPermissions(t *testing.T) {
 				return &mockCloudFormation{
 					t: t,
 					mockCreateChangeSet: func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error) {
-						require.Equal(t, 5, len(in.Parameters))
+						require.Equal(t, 6, len(in.Parameters))
 						return &cloudformation.CreateChangeSetOutput{
 							StackId: aws.String("stackname"),
 						}, nil
@@ -1096,7 +1096,7 @@ func TestDelegateDNSPermissions(t *testing.T) {
 				return &mockCloudFormation{
 					t: t,
 					mockCreateChangeSet: func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error) {
-						require.Equal(t, 5, len(in.Parameters))
+						require.Equal(t, 6, len(in.Parameters))
 						return &cloudformation.CreateChangeSetOutput{
 							StackId: aws.String("stackname"),
 						}, nil
diff --git a/internal/pkg/deploy/cloudformation/stack/env_test.go b/internal/pkg/deploy/cloudformation/stack/env_test.go
index 7887471f..577b96fd 100644
--- a/internal/pkg/deploy/cloudformation/stack/env_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/env_test.go
@@ -27,7 +27,7 @@ func TestEnvTemplate(t *testing.T) {
 			want: fmt.Errorf("failed to find the cloudformation template at %s", EnvTemplatePath),
 		},
 		"should return template body when present": {
-			box:            envBoxWithTemplateFile(),
+			box:            envBoxWithAllTemplateFiles(),
 			expectedOutput: mockTemplate,
 		},
 	}
@@ -49,26 +49,128 @@ func TestEnvTemplate(t *testing.T) {
 
 func TestEnvParameters(t *testing.T) {
 	deploymentInput := mockDeployEnvironmentInput()
-	env := NewEnvStackConfig(deploymentInput, emptyEnvBox())
-	expectedParams := []*cloudformation.Parameter{
-		{
-			ParameterKey:   aws.String(envParamIncludeLBKey),
-			ParameterValue: aws.String(strconv.FormatBool(deploymentInput.PublicLoadBalancer)),
+	deploymentInputWithDNS := mockDeployEnvironmentInput()
+	deploymentInputWithDNS.ProjectDNSName = "ecs.aws"
+	testCases := map[string]struct {
+		input *deploy.CreateEnvironmentInput
+		want  []*cloudformation.Parameter
+	}{
+		"without DNS": {
+			input: deploymentInput,
+			want: []*cloudformation.Parameter{
+				{
+					ParameterKey:   aws.String(envParamIncludeLBKey),
+					ParameterValue: aws.String(strconv.FormatBool(deploymentInput.PublicLoadBalancer)),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectNameKey),
+					ParameterValue: aws.String(deploymentInput.Project),
+				},
+				{
+					ParameterKey:   aws.String(envParamEnvNameKey),
+					ParameterValue: aws.String(deploymentInput.Name),
+				},
+				{
+					ParameterKey:   aws.String(envParamToolsAccountPrincipalKey),
+					ParameterValue: aws.String(deploymentInput.ToolsAccountPrincipalARN),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectDNSKey),
+					ParameterValue: aws.String(""),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectDNSDelegationRoleKey),
+					ParameterValue: aws.String(""),
+				},
+			},
 		},
-		{
-			ParameterKey:   aws.String(envParamProjectNameKey),
-			ParameterValue: aws.String(deploymentInput.Project),
+		"with DNS": {
+			input: deploymentInputWithDNS,
+			want: []*cloudformation.Parameter{
+				{
+					ParameterKey:   aws.String(envParamIncludeLBKey),
+					ParameterValue: aws.String(strconv.FormatBool(deploymentInputWithDNS.PublicLoadBalancer)),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectNameKey),
+					ParameterValue: aws.String(deploymentInputWithDNS.Project),
+				},
+				{
+					ParameterKey:   aws.String(envParamEnvNameKey),
+					ParameterValue: aws.String(deploymentInputWithDNS.Name),
+				},
+				{
+					ParameterKey:   aws.String(envParamToolsAccountPrincipalKey),
+					ParameterValue: aws.String(deploymentInputWithDNS.ToolsAccountPrincipalARN),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectDNSKey),
+					ParameterValue: aws.String(deploymentInputWithDNS.ProjectDNSName),
+				},
+				{
+					ParameterKey:   aws.String(envParamProjectDNSDelegationRoleKey),
+					ParameterValue: aws.String("arn:aws:iam::000000000:role/project-DNSDelegationRole"),
+				},
+			},
 		},
-		{
-			ParameterKey:   aws.String(envParamEnvNameKey),
-			ParameterValue: aws.String(deploymentInput.Name),
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			env := NewEnvStackConfig(tc.input, emptyEnvBox())
+			require.ElementsMatch(t, tc.want, env.Parameters())
+		})
+	}
+}
+
+func TestEnvDNSDelegationRole(t *testing.T) {
+	testCases := map[string]struct {
+		input *EnvStackConfig
+		want  string
+	}{
+		"without tools account ARN": {
+			want: "",
+			input: &EnvStackConfig{
+				CreateEnvironmentInput: &deploy.CreateEnvironmentInput{
+					ToolsAccountPrincipalARN: "",
+					ProjectDNSName:           "ecs.aws",
+				},
+			},
 		},
-		{
-			ParameterKey:   aws.String(envParamToolsAccountPrincipalKey),
-			ParameterValue: aws.String(deploymentInput.ToolsAccountPrincipalARN),
+		"without DNS": {
+			want: "",
+			input: &EnvStackConfig{
+				CreateEnvironmentInput: &deploy.CreateEnvironmentInput{
+					ToolsAccountPrincipalARN: "arn:aws:iam::0000000:root",
+					ProjectDNSName:           "",
+				},
+			},
+		},
+		"with invalid tools principal": {
+			want: "",
+			input: &EnvStackConfig{
+				CreateEnvironmentInput: &deploy.CreateEnvironmentInput{
+					ToolsAccountPrincipalARN: "0000000",
+					ProjectDNSName:           "ecs.aws",
+				},
+			},
 		},
+		"with dns and tools principal": {
+			want: "arn:aws:iam::0000000:role/-DNSDelegationRole",
+			input: &EnvStackConfig{
+				CreateEnvironmentInput: &deploy.CreateEnvironmentInput{
+					ToolsAccountPrincipalARN: "arn:aws:iam::0000000:root",
+					ProjectDNSName:           "ecs.aws",
+				},
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			require.Equal(t, tc.want, tc.input.dnsDelegationRole())
+		})
 	}
-	require.ElementsMatch(t, expectedParams, env.Parameters())
 }
 
 func TestEnvTags(t *testing.T) {
@@ -149,7 +251,7 @@ func mockDeployEnvironmentInput() *deploy.CreateEnvironmentInput {
 		Project:                  "project",
 		Prod:                     true,
 		PublicLoadBalancer:       true,
-		ToolsAccountPrincipalARN: "mockToolsAccountPrincipalARN",
+		ToolsAccountPrincipalARN: "arn:aws:iam::000000000:root",
 	}
 }
 
@@ -157,10 +259,12 @@ func emptyEnvBox() packd.Box {
 	return packd.NewMemoryBox()
 }
 
-func envBoxWithTemplateFile() packd.Box {
+func envBoxWithAllTemplateFiles() packd.Box {
 	box := packd.NewMemoryBox()
 
 	box.AddString(EnvTemplatePath, mockTemplate)
+	box.AddString(acmValidationTemplatePath, "customresources")
+	box.AddString(dnsDelegationTemplatePath, "customresources")
 
 	return box
 }
diff --git a/internal/pkg/deploy/cloudformation/stack/project_test.go b/internal/pkg/deploy/cloudformation/stack/project_test.go
index 19006c6b..abc70a3a 100644
--- a/internal/pkg/deploy/cloudformation/stack/project_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/project_test.go
@@ -174,6 +174,10 @@ func TestProjectParameters(t *testing.T) {
 			ParameterKey:   aws.String(projectDomainNameKey),
 			ParameterValue: aws.String("amazon.com"),
 		},
+		{
+			ParameterKey:   aws.String(projectDNSDelegationRoleParamName),
+			ParameterValue: aws.String("testproject-DNSDelegationRole"),
+		},
 		{
 			ParameterKey:   aws.String(projectNameKey),
 			ParameterValue: aws.String("testproject"),