diff --git a/src/pkg/cli/compose/context_test.go b/src/pkg/cli/compose/context_test.go index dff3bf3c6..7b62f85f2 100644 --- a/src/pkg/cli/compose/context_test.go +++ b/src/pkg/cli/compose/context_test.go @@ -223,7 +223,7 @@ func Test_getRemoteBuildContext(t *testing.T) { }, } - tmpDir := t.TempDir() + tmpDir := t.TempDir() // change this to "/tmp" or so to inspect the files server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { diff --git a/src/pkg/clouds/aws/ecs/cfn/oidc.go b/src/pkg/clouds/aws/ecs/cfn/oidc.go index 555688995..9877cccd4 100644 --- a/src/pkg/clouds/aws/ecs/cfn/oidc.go +++ b/src/pkg/clouds/aws/ecs/cfn/oidc.go @@ -11,9 +11,12 @@ import ( type Jwk struct { Kty string `json:"kty"` + Kid string `json:"kid,omitempty"` Alg string `json:"alg,omitempty"` Use string `json:"use,omitempty"` - X5c []string `json:"x5c,omitempty"` + N string `json:"n,omitempty"` // RSA modulus, base64url-encoded + E string `json:"e,omitempty"` // RSA exponent, base64url-encoded + X5c [][]byte `json:"x5c,omitempty"` // DER-encoded cert(s) X5t string `json:"x5t,omitempty"` // base64url-encoded } @@ -60,12 +63,16 @@ func FetchThumbprints(iss string) ([]string, error) { var thumbprints []string for _, key := range jwks.Keys { - if len(key.X5c) > 0 { - decoded, err := base64.RawURLEncoding.DecodeString(key.X5t) + if key.X5t != "" { + thumbprint, err := base64.RawURLEncoding.DecodeString(key.X5t) if err != nil { return nil, fmt.Errorf("invalid base64url encoding in x5t claim: %w", err) } - thumbprints = append(thumbprints, hex.EncodeToString(decoded)) + thumbprints = append(thumbprints, hex.EncodeToString(thumbprint)) + } else if len(key.X5c) > 0 { + // Compute SHA-1 thumbprint of DER-encoded cert + // thumbprint := sha1.Sum(key.X5c[0]) not important; avoid importing sha1 + // thumbprints = append(thumbprints, hex.EncodeToString(thumbprint[:])) } } return thumbprints, nil diff --git a/src/pkg/clouds/aws/ecs/cfn/outputs.go b/src/pkg/clouds/aws/ecs/cfn/outputs.go index fcd04a5a6..706607a59 100644 --- a/src/pkg/clouds/aws/ecs/cfn/outputs.go +++ b/src/pkg/clouds/aws/ecs/cfn/outputs.go @@ -8,6 +8,6 @@ const ( OutputsLogGroupARN = "logGroupArn" OutputsSecurityGroupID = "securityGroupId" OutputsSubnetID = "subnetId" - OutputsTaskDefArn = "taskDefArn" + OutputsTaskDefARN = "taskDefArn" OutputsTemplateVersion = "templateVersion" ) diff --git a/src/pkg/clouds/aws/ecs/cfn/setup.go b/src/pkg/clouds/aws/ecs/cfn/setup.go index 04be5824a..3173cf352 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup.go +++ b/src/pkg/clouds/aws/ecs/cfn/setup.go @@ -161,10 +161,6 @@ func (a *AwsEcsCfn) SetUp(ctx context.Context, containers []clouds.Container) er return err } - return a.upsertStackAndWait(ctx, templateBody) -} - -func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte) error { // Set parameter values based on current configuration parameters := []cfnTypes.Parameter{ // { @@ -200,6 +196,10 @@ func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte) } // TODO: support DOCKER_AUTH_CONFIG + return a.upsertStackAndWait(ctx, templateBody, parameters...) +} + +func (a *AwsEcsCfn) upsertStackAndWait(ctx context.Context, templateBody []byte, parameters ...cfnTypes.Parameter) error { // Upsert with parameters if err := a.updateStackAndWait(ctx, string(templateBody), parameters); err != nil { // Check if the stack doesn't exist; if so, create it, otherwise return the error @@ -249,7 +249,7 @@ func (a *AwsEcsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) er } case OutputsDefaultSecurityGroupID: a.DefaultSecurityGroupID = *output.OutputValue - case OutputsTaskDefArn: + case OutputsTaskDefARN: a.TaskDefARN = *output.OutputValue case OutputsClusterName: a.ClusterName = *output.OutputValue @@ -259,6 +259,8 @@ func (a *AwsEcsCfn) fillWithOutputs(dso *cloudformation.DescribeStacksOutput) er a.SecurityGroupID = *output.OutputValue case OutputsBucketName: a.BucketName = *output.OutputValue + case OutputsCIRoleARN: + a.CIRoleARN = *output.OutputValue } } diff --git a/src/pkg/clouds/aws/ecs/cfn/setup_test.go b/src/pkg/clouds/aws/ecs/cfn/setup_test.go index ca5cbc018..99364e3be 100644 --- a/src/pkg/clouds/aws/ecs/cfn/setup_test.go +++ b/src/pkg/clouds/aws/ecs/cfn/setup_test.go @@ -26,15 +26,15 @@ func TestCloudFormation(t *testing.T) { aws.RetainBucket = false // delete bucket after test aws.Spot = true - ctx := context.Background() + ctx := t.Context() t.Run("SetUp", func(t *testing.T) { - template := createTestTemplate(t) // Enable fancy features so we can test all conditional resources t.Setenv("DEFANG_NO_CACHE", "0") // force cache usage t.Setenv("DOCKERHUB_USERNAME", "defanglabs2") t.Setenv("DOCKERHUB_ACCESS_TOKEN", "defanglabs") - err := aws.upsertStackAndWait(ctx, template) + + err := aws.SetUp(ctx, testContainers) if err != nil { t.Fatal(err) } diff --git a/src/pkg/clouds/aws/ecs/cfn/template.go b/src/pkg/clouds/aws/ecs/cfn/template.go index cb8e3f20e..5d0849f2b 100644 --- a/src/pkg/clouds/aws/ecs/cfn/template.go +++ b/src/pkg/clouds/aws/ecs/cfn/template.go @@ -143,6 +143,43 @@ func CreateTemplate(stack string, containers []clouds.Container) (*cloudformatio Description: ptr.String(`Additional OIDC claim conditions as comma-separated JSON "key":"value" pairs (optional)`), } + // Metadata - AWS::CloudFormation::Interface for parameter grouping and labels + template.Metadata = map[string]interface{}{ + "AWS::CloudFormation::Interface": map[string]interface{}{ + "ParameterGroups": []map[string]interface{}{ + { + "Label": map[string]string{"default": "CI/CD Integration (OIDC)"}, + "Parameters": []string{ParamsOidcProviderIssuer, ParamsOidcProviderSubjects, ParamsOidcProviderAudiences, ParamsCIRoleName, ParamsOidcProviderThumbprints, ParamsOidcProviderClaims}, + }, + { + "Label": map[string]string{"default": "Network Configuration"}, + "Parameters": []string{ParamsExistingVpcId}, + }, + { + "Label": map[string]string{"default": "Container Registry (ECR Pull-Through Cache)"}, + "Parameters": []string{ParamsEnablePullThroughCache, ParamsDockerHubUsername, ParamsDockerHubAccessToken}, + }, + { + "Label": map[string]string{"default": "Storage Configuration"}, + "Parameters": []string{ParamsRetainBucket}, + }, + }, + "ParameterLabels": map[string]interface{}{ + ParamsExistingVpcId: map[string]string{"default": "Existing VPC ID"}, + ParamsRetainBucket: map[string]string{"default": "Retain S3 Bucket on Delete"}, + ParamsEnablePullThroughCache: map[string]string{"default": "Enable ECR Pull-Through Cache"}, + ParamsDockerHubUsername: map[string]string{"default": "Docker Hub Username"}, + ParamsDockerHubAccessToken: map[string]string{"default": "Docker Hub Access Token"}, + ParamsOidcProviderIssuer: map[string]string{"default": "OIDC Provider Issuer URL"}, + ParamsOidcProviderSubjects: map[string]string{"default": "OIDC Trusted Subject Patterns"}, + ParamsOidcProviderAudiences: map[string]string{"default": "OIDC Trusted Audiences"}, + ParamsOidcProviderThumbprints: map[string]string{"default": "OIDC Provider Thumbprints"}, + ParamsOidcProviderClaims: map[string]string{"default": "Additional OIDC Claim Conditions"}, + ParamsCIRoleName: map[string]string{"default": "CI Role Name"}, + }, + }, + } + // Conditions const _condCreateVpcResources = "CreateVpcResources" template.Conditions[_condCreateVpcResources] = cloudformation.Equals(cloudformation.Ref(ParamsExistingVpcId), "") @@ -672,9 +709,9 @@ func CreateTemplate(stack string, containers []clouds.Container) (*cloudformatio template.Outputs[OutputsCIRoleARN] = cloudformation.Output{ Condition: ptr.String(_condOidcProvider), Description: ptr.String("ARN of the CI role"), - Value: cloudformation.Ref(_CIRole), + Value: cloudformation.GetAtt(_CIRole, "Arn"), } - template.Outputs[OutputsTaskDefArn] = cloudformation.Output{ + template.Outputs[OutputsTaskDefARN] = cloudformation.Output{ Description: ptr.String("ARN of the ECS task definition"), Value: cloudformation.Ref(_taskDefinition), } diff --git a/src/pkg/clouds/aws/ecs/cfn/template_test.go b/src/pkg/clouds/aws/ecs/cfn/template_test.go index b7d502383..4c88b4c5c 100644 --- a/src/pkg/clouds/aws/ecs/cfn/template_test.go +++ b/src/pkg/clouds/aws/ecs/cfn/template_test.go @@ -43,31 +43,33 @@ func TestGetCacheRepoPrefix(t *testing.T) { } } +var testContainers = []clouds.Container{ + { + Image: "alpine:latest", + }, + { + Image: "docker.io/library/alpine:latest", + Name: "main2", + }, + { + Name: "main3", + Image: "public.ecr.aws/docker/library/alpine:latest", + Memory: 512_000_000, + Platform: "linux/amd64", + }, +} + func createTestTemplate(t *testing.T) []byte { t.Helper() - template, err := CreateTemplate("test", []clouds.Container{ - { - Image: "alpine:latest", - }, - { - Image: "docker.io/library/alpine:latest", - Name: "main2", - }, - { - Name: "main3", - Image: "public.ecr.aws/docker/library/alpine:latest", - Memory: 512_000_000, - Platform: "linux/amd64", - }, - }) + template, err := CreateTemplate("test", testContainers) if err != nil { t.Fatalf("Error creating template: %v", err) } - actual, err := template.YAML() + templateBody, err := template.YAML() if err != nil { t.Fatalf("Error generating template YAML: %v", err) } - return actual + return templateBody } func TestCreateTemplate(t *testing.T) { diff --git a/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml b/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml index 2656878db..39c5e00bb 100644 --- a/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml +++ b/src/pkg/clouds/aws/ecs/cfn/testdata/template.yaml @@ -57,6 +57,55 @@ Conditions: - Ref: RetainBucket - "true" Description: 'Defang AWS CloudFormation template for the CD task. Do not delete this stack in the AWS console: use the Defang CLI instead. To create this stack, scroll down to acknowledge the risks and press ''Create stack''.' +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: CI/CD Integration (OIDC) + Parameters: + - OidcProviderIssuer + - OidcProviderSubjects + - OidcProviderAudiences + - CIRoleName + - OidcProviderThumbprints + - OidcProviderClaims + - Label: + default: Network Configuration + Parameters: + - ExistingVpcId + - Label: + default: Container Registry (ECR Pull-Through Cache) + Parameters: + - EnablePullThroughCache + - DockerHubUsername + - DockerHubAccessToken + - Label: + default: Storage Configuration + Parameters: + - RetainBucket + ParameterLabels: + CIRoleName: + default: CI Role Name + DockerHubAccessToken: + default: Docker Hub Access Token + DockerHubUsername: + default: Docker Hub Username + EnablePullThroughCache: + default: Enable ECR Pull-Through Cache + ExistingVpcId: + default: Existing VPC ID + OidcProviderAudiences: + default: OIDC Trusted Audiences + OidcProviderClaims: + default: Additional OIDC Claim Conditions + OidcProviderIssuer: + default: OIDC Provider Issuer URL + OidcProviderSubjects: + default: OIDC Trusted Subject Patterns + OidcProviderThumbprints: + default: OIDC Provider Thumbprints + RetainBucket: + default: Retain S3 Bucket on Delete Outputs: bucketName: Description: Name of the S3 bucket @@ -66,7 +115,9 @@ Outputs: Condition: OidcProvider Description: ARN of the CI role Value: - Ref: CIRole + Fn::GetAtt: + - CIRole + - Arn clusterName: Description: Name of the ECS cluster Value: diff --git a/src/pkg/clouds/aws/ecs/common.go b/src/pkg/clouds/aws/ecs/common.go index ea72a518f..8d013516a 100644 --- a/src/pkg/clouds/aws/ecs/common.go +++ b/src/pkg/clouds/aws/ecs/common.go @@ -19,6 +19,7 @@ type TaskArn = clouds.TaskID type AwsEcs struct { aws.Aws BucketName string + CIRoleARN string ClusterName string DefaultSecurityGroupID string LogGroupARN string