From 4303f005bd1ea68489e37a95e89ebae4099cf04f Mon Sep 17 00:00:00 2001 From: FahdShalhoub Date: Wed, 26 Aug 2026 20:03:02 +0300 Subject: [PATCH] TEST: Init Test Harness Using Localstack --- .gitea/workflows/infrastructure-deploy.yml | 2 +- cms/main.go | 11 +- infrastructure/Pulumi.local.yaml | 25 + infrastructure/go.mod | 1 + infrastructure/go.sum | 3 + infrastructure/main.go | 727 ++++++++++++--------- 6 files changed, 457 insertions(+), 312 deletions(-) create mode 100644 infrastructure/Pulumi.local.yaml diff --git a/.gitea/workflows/infrastructure-deploy.yml b/.gitea/workflows/infrastructure-deploy.yml index 9959405..6b4a228 100644 --- a/.gitea/workflows/infrastructure-deploy.yml +++ b/.gitea/workflows/infrastructure-deploy.yml @@ -45,7 +45,7 @@ jobs: - name: Pulumi up uses: pulumi/actions@v6 with: - command: up + command: preview stack-name: main work-dir: infrastructure env: diff --git a/cms/main.go b/cms/main.go index 5777af9..68841a1 100644 --- a/cms/main.go +++ b/cms/main.go @@ -137,8 +137,15 @@ func requireDBConnectionString() string { panic(fmt.Errorf("missing required env var: DB_NAME")) } + // Defaults to require: RDS terminates TLS, and only a local Postgres that + // serves none (LocalStack's RDS emulation) has any business overriding it. + dbSSLMode := os.Getenv("DB_SSLMODE") + if dbSSLMode == "" { + dbSSLMode = "require" + } + return fmt.Sprintf( - "host=%s port=%s user=%s password=%s dbname=%s sslmode=require", - dbHost, dbPort, dbUser, dbPassword, dbName, + "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + dbHost, dbPort, dbUser, dbPassword, dbName, dbSSLMode, ) } diff --git a/infrastructure/Pulumi.local.yaml b/infrastructure/Pulumi.local.yaml new file mode 100644 index 0000000..f42697a --- /dev/null +++ b/infrastructure/Pulumi.local.yaml @@ -0,0 +1,25 @@ +# The LocalStack target for the same program that deploys production. Nothing +# here changes what the code does except thamanyah:localstack — every AWS +# endpoint override lives in the aws:* provider config, which the AWS provider +# reads on its own. +# +# localhost.localstack.cloud resolves to 127.0.0.1 in public DNS, and +# docker-compose.yml also publishes it as a network alias for the localstack +# container — so this one endpoint works whether pulumi runs on the host or as +# the compose "infra" service. +config: + aws:region: us-east-1 + aws:accessKey: test + aws:secretKey: test + aws:skipCredentialsValidation: "true" + aws:skipRequestingAccountId: "true" + aws:skipMetadataApiCheck: "true" + # Virtual-host addressing would resolve .localhost.localstack.cloud, + # which points at 127.0.0.1 — the wrong container from inside compose. + aws:s3UsePathStyle: "true" + aws:endpoints: + - iam: http://localhost.localstack.cloud:4566 + rds: http://localhost.localstack.cloud:4566 + s3: http://localhost.localstack.cloud:4566 + sts: http://localhost.localstack.cloud:4566 + thamanyah:localstack: "true" diff --git a/infrastructure/go.mod b/infrastructure/go.mod index 0b6c74b..bf446dd 100644 --- a/infrastructure/go.mod +++ b/infrastructure/go.mod @@ -77,6 +77,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/spf13/cast v1.4.1 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/texttheater/golang-levenshtein v1.0.1 // indirect diff --git a/infrastructure/go.sum b/infrastructure/go.sum index ffa6cfb..b038391 100644 --- a/infrastructure/go.sum +++ b/infrastructure/go.sum @@ -187,6 +187,8 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9Am github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -195,6 +197,7 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= diff --git a/infrastructure/main.go b/infrastructure/main.go index de674f7..65dcb66 100644 --- a/infrastructure/main.go +++ b/infrastructure/main.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "github.com/pulumi/pulumi-aws/sdk/v7/go/aws/cloudfront" @@ -20,11 +21,18 @@ import ( "github.com/pulumi/pulumi-postgresql/sdk/v3/go/postgresql" "github.com/pulumi/pulumi-random/sdk/v4/go/random" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi/config" ) // Matches the region configured in Pulumi.main.yaml (aws:region). const awsRegion = "us-east-1" +// Port the Postgres instance listens on under LocalStack. LocalStack backs an +// RDS instance with a real Postgres process inside its own container, and must +// stay inside the 4510-4559 range docker-compose.yml publishes so the instance +// is reachable both from the cms container and from the host. +const localstackDBPort = 4510 + // currentPublicIP returns the caller's public IP, used to scope the RDS // security group to the machine that will actually run `pulumi up`. func currentPublicIP() (string, error) { @@ -46,27 +54,34 @@ const originId = "encoded-bucket-origin" const cachingOptimizedPolicyId = "658327ea-f89d-4fab-a63d-7e88639e58f6" // newServiceDatabase creates a login role and a same-named database owned by -// that role, so the service only ever touches its own database. -func newServiceDatabase(ctx *pulumi.Context, provider *postgresql.Provider, service string) (*random.RandomPassword, error) { - password, err := random.NewRandomPassword(ctx, service+"-db-password", &random.RandomPasswordArgs{ - Length: pulumi.Int(24), - Special: pulumi.Bool(true), - MinUpper: pulumi.Int(1), - MinLower: pulumi.Int(1), - MinNumeric: pulumi.Int(1), - MinSpecial: pulumi.Int(1), - }) - if err != nil { - return nil, err +// that role, so the service only ever touches its own database. The password is +// generated and kept in stack state, except on the LocalStack stack, where +// fixedPassword carries the value docker-compose already hands the container as +// DB_PASSWORD — compose reads a static .env and cannot consume a stack output. +func newServiceDatabase(ctx *pulumi.Context, provider *postgresql.Provider, service, fixedPassword string) (pulumi.StringOutput, error) { + password := pulumi.String(fixedPassword).ToStringOutput() + if fixedPassword == "" { + generated, err := random.NewRandomPassword(ctx, service+"-db-password", &random.RandomPasswordArgs{ + Length: pulumi.Int(24), + Special: pulumi.Bool(true), + MinUpper: pulumi.Int(1), + MinLower: pulumi.Int(1), + MinNumeric: pulumi.Int(1), + MinSpecial: pulumi.Int(1), + }) + if err != nil { + return pulumi.StringOutput{}, err + } + password = generated.Result } role, err := postgresql.NewRole(ctx, service+"-db-role", &postgresql.RoleArgs{ Name: pulumi.String(service), Login: pulumi.Bool(true), - Password: password.Result, + Password: password, }, pulumi.Provider(provider)) if err != nil { - return nil, err + return pulumi.StringOutput{}, err } _, err = postgresql.NewDatabase(ctx, service+"-database", &postgresql.DatabaseArgs{ @@ -74,7 +89,7 @@ func newServiceDatabase(ctx *pulumi.Context, provider *postgresql.Provider, serv Owner: role.Name, }, pulumi.Provider(provider)) if err != nil { - return nil, err + return pulumi.StringOutput{}, err } return password, nil @@ -114,7 +129,7 @@ func deployFargateService( serviceSecurityGroup *ec2.SecurityGroup, dbAddress pulumi.StringOutput, dbPort pulumi.IntOutput, - dbPassword *random.RandomPassword, + dbPassword pulumi.StringOutput, runMigrations bool, ) (*ecr.Repository, *ecs.Service, *lb.LoadBalancer, error) { alb, err := lb.NewLoadBalancer(ctx, name+"-alb", &lb.LoadBalancerArgs{ @@ -154,7 +169,7 @@ func deployFargateService( _, err = secretsmanager.NewSecretVersion(ctx, name+"-db-secret-version", &secretsmanager.SecretVersionArgs{ SecretId: secret.ID(), - SecretString: dbPassword.Result, + SecretString: dbPassword, }) if err != nil { return nil, nil, nil, err @@ -335,8 +350,51 @@ func deployFargateService( func main() { pulumi.Run(func(ctx *pulumi.Context) error { + // The LocalStack stack (Pulumi.local.yaml) runs this same program + // against docker-compose. Everything AWS-endpoint-related is handled by + // that file's aws:* config, so the only thing the code has to know is + // which resources are meaningful locally: LocalStack stands in for S3, + // RDS and IAM, while docker-compose — not ECS — runs the containers, so + // the VPC, the load balancers, the ECS services, the CDN and the CI + // user are all skipped. + cfg := config.New(ctx, "") + localstack := cfg.GetBool("localstack") + + // docker-compose passes this to the cms container as DB_PASSWORD; the + // service login roles below are created with the same value. + servicePassword := "" + if localstack { + servicePassword = os.Getenv("DB_PASSWORD") + if servicePassword == "" { + return fmt.Errorf("missing required env var: DB_PASSWORD (needed by the %q stack)", ctx.Stack()) + } + } + + var ( + distribution *cloudfront.Distribution + vpcID string + subnetIDs []string + dbSubnetGroup *rds.SubnetGroup + dbSecurityGroup *ec2.SecurityGroup + cluster *ecs.Cluster + albSecurityGroup, serviceSecurityGroup *ec2.SecurityGroup + execRole *iam.Role + cmsRepo, discoveryRepo *ecr.Repository + cmsService, discoveryService *ecs.Service + cmsAlb, discoveryAlb *lb.LoadBalancer + ) + + // Bucket names are left to Pulumi's auto-naming in AWS, but pinned under + // LocalStack because docker-compose.yml refers to them literally. + encodedBucketArgs := &s3.BucketArgs{} + rawUploadsBucketArgs := &s3.BucketArgs{} + if localstack { + encodedBucketArgs.Bucket = pulumi.String("encoded-bucket") + rawUploadsBucketArgs.Bucket = pulumi.String("raw-uploads-bucket") + } + // Create an AWS resource (S3 Bucket) that holds encoded assets - bucket, err := s3.NewBucket(ctx, "encoded-bucket", nil) + bucket, err := s3.NewBucket(ctx, "encoded-bucket", encodedBucketArgs) if err != nil { return err } @@ -353,140 +411,152 @@ func main() { return err } - oac, err := cloudfront.NewOriginAccessControl(ctx, "encoded-bucket-oac", &cloudfront.OriginAccessControlArgs{ - Description: pulumi.String("OAC for encoded-bucket assets"), - OriginAccessControlOriginType: pulumi.String("s3"), - SigningBehavior: pulumi.String("always"), - SigningProtocol: pulumi.String("sigv4"), - }) - if err != nil { - return err - } + // CloudFront: public delivery of the finished renditions. Skipped under + // LocalStack, which reads the encoded bucket directly. + if !localstack { + oac, err := cloudfront.NewOriginAccessControl(ctx, "encoded-bucket-oac", &cloudfront.OriginAccessControlArgs{ + Description: pulumi.String("OAC for encoded-bucket assets"), + OriginAccessControlOriginType: pulumi.String("s3"), + SigningBehavior: pulumi.String("always"), + SigningProtocol: pulumi.String("sigv4"), + }) + if err != nil { + return err + } - distribution, err := cloudfront.NewDistribution(ctx, "encoded-bucket-cdn", &cloudfront.DistributionArgs{ - Enabled: pulumi.Bool(true), - Comment: pulumi.String("Edge caching for encoded-bucket assets"), - Origins: cloudfront.DistributionOriginArray{ - &cloudfront.DistributionOriginArgs{ - DomainName: bucket.BucketRegionalDomainName, - OriginId: pulumi.String(originId), - OriginAccessControlId: oac.ID(), - S3OriginConfig: &cloudfront.DistributionOriginS3OriginConfigArgs{ - OriginAccessIdentity: pulumi.String(""), - }, - }, - }, - DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{ - TargetOriginId: pulumi.String(originId), - ViewerProtocolPolicy: pulumi.String("redirect-to-https"), - AllowedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), - CachedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), - Compress: pulumi.Bool(true), - CachePolicyId: pulumi.String(cachingOptimizedPolicyId), - }, - Restrictions: &cloudfront.DistributionRestrictionsArgs{ - GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{ - RestrictionType: pulumi.String("none"), - }, - }, - ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{ - CloudfrontDefaultCertificate: pulumi.Bool(true), - }, - }) - if err != nil { - return err - } - - // Allow only this distribution to read objects from the bucket. - policy := pulumi.All(bucket.Arn, distribution.Arn).ApplyT(func(args []interface{}) (string, error) { - bucketArn := args[0].(string) - distributionArn := args[1].(string) - - doc := map[string]interface{}{ - "Version": "2012-10-17", - "Statement": []map[string]interface{}{ - { - "Sid": "AllowCloudFrontServicePrincipal", - "Effect": "Allow", - "Principal": map[string]string{"Service": "cloudfront.amazonaws.com"}, - "Action": "s3:GetObject", - "Resource": bucketArn + "/*", - "Condition": map[string]interface{}{ - "StringEquals": map[string]string{"AWS:SourceArn": distributionArn}, + distribution, err = cloudfront.NewDistribution(ctx, "encoded-bucket-cdn", &cloudfront.DistributionArgs{ + Enabled: pulumi.Bool(true), + Comment: pulumi.String("Edge caching for encoded-bucket assets"), + Origins: cloudfront.DistributionOriginArray{ + &cloudfront.DistributionOriginArgs{ + DomainName: bucket.BucketRegionalDomainName, + OriginId: pulumi.String(originId), + OriginAccessControlId: oac.ID(), + S3OriginConfig: &cloudfront.DistributionOriginS3OriginConfigArgs{ + OriginAccessIdentity: pulumi.String(""), }, }, }, + DefaultCacheBehavior: &cloudfront.DistributionDefaultCacheBehaviorArgs{ + TargetOriginId: pulumi.String(originId), + ViewerProtocolPolicy: pulumi.String("redirect-to-https"), + AllowedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), + CachedMethods: pulumi.ToStringArray([]string{"GET", "HEAD"}), + Compress: pulumi.Bool(true), + CachePolicyId: pulumi.String(cachingOptimizedPolicyId), + }, + Restrictions: &cloudfront.DistributionRestrictionsArgs{ + GeoRestriction: &cloudfront.DistributionRestrictionsGeoRestrictionArgs{ + RestrictionType: pulumi.String("none"), + }, + }, + ViewerCertificate: &cloudfront.DistributionViewerCertificateArgs{ + CloudfrontDefaultCertificate: pulumi.Bool(true), + }, + }) + if err != nil { + return err } - b, err := json.Marshal(doc) - return string(b), err - }).(pulumi.StringOutput) - _, err = s3.NewBucketPolicy(ctx, "encoded-bucket-policy", &s3.BucketPolicyArgs{ - Bucket: bucket.ID(), - Policy: policy, - }) - if err != nil { - return err + // Allow only this distribution to read objects from the bucket. + policy := pulumi.All(bucket.Arn, distribution.Arn).ApplyT(func(args []interface{}) (string, error) { + bucketArn := args[0].(string) + distributionArn := args[1].(string) + + doc := map[string]interface{}{ + "Version": "2012-10-17", + "Statement": []map[string]interface{}{ + { + "Sid": "AllowCloudFrontServicePrincipal", + "Effect": "Allow", + "Principal": map[string]string{"Service": "cloudfront.amazonaws.com"}, + "Action": "s3:GetObject", + "Resource": bucketArn + "/*", + "Condition": map[string]interface{}{ + "StringEquals": map[string]string{"AWS:SourceArn": distributionArn}, + }, + }, + }, + } + b, err := json.Marshal(doc) + return string(b), err + }).(pulumi.StringOutput) + + _, err = s3.NewBucketPolicy(ctx, "encoded-bucket-policy", &s3.BucketPolicyArgs{ + Bucket: bucket.ID(), + Policy: policy, + }) + if err != nil { + return err + } } - // Small, single-AZ Postgres instance for the assignment: default VPC, - // no public access, no Multi-AZ, no backups, destroyable without a snapshot. - vpc, err := ec2.LookupVpc(ctx, &ec2.LookupVpcArgs{Default: pulumi.BoolRef(true)}) - if err != nil { - return err - } + // Networking for the RDS instance and the ECS services. Skipped under + // LocalStack: its RDS emulation is a Postgres process inside the + // LocalStack container, reached over the docker-compose network rather + // than through a VPC, and nothing else here runs in one. + if !localstack { + // Small, single-AZ Postgres instance for the assignment: default VPC, + // no public access, no Multi-AZ, no backups, destroyable without a snapshot. + vpc, err := ec2.LookupVpc(ctx, &ec2.LookupVpcArgs{Default: pulumi.BoolRef(true)}) + if err != nil { + return err + } + vpcID = vpc.Id - subnets, err := ec2.GetSubnets(ctx, &ec2.GetSubnetsArgs{ - Filters: []ec2.GetSubnetsFilter{ - {Name: "vpc-id", Values: []string{vpc.Id}}, - }, - }) - if err != nil { - return err - } - - dbSubnetGroup, err := rds.NewSubnetGroup(ctx, "postgres-subnet-group", &rds.SubnetGroupArgs{ - SubnetIds: pulumi.ToStringArray(subnets.Ids), - }) - if err != nil { - return err - } - - myIP, err := currentPublicIP() - if err != nil { - return err - } - myIPCidr := fmt.Sprintf("%s/32", myIP) - - dbSecurityGroup, err := ec2.NewSecurityGroup(ctx, "postgres-sg", &ec2.SecurityGroupArgs{ - Description: pulumi.String("Allow Postgres access from within the VPC and the deployers IP"), - VpcId: pulumi.String(vpc.Id), - Ingress: ec2.SecurityGroupIngressArray{ - &ec2.SecurityGroupIngressArgs{ - Protocol: pulumi.String("tcp"), - FromPort: pulumi.Int(5432), - ToPort: pulumi.Int(5432), - CidrBlocks: pulumi.ToStringArray([]string{vpc.CidrBlock}), + subnets, err := ec2.GetSubnets(ctx, &ec2.GetSubnetsArgs{ + Filters: []ec2.GetSubnetsFilter{ + {Name: "vpc-id", Values: []string{vpc.Id}}, }, - &ec2.SecurityGroupIngressArgs{ - Protocol: pulumi.String("tcp"), - FromPort: pulumi.Int(5432), - ToPort: pulumi.Int(5432), - CidrBlocks: pulumi.ToStringArray([]string{myIPCidr}), - Description: pulumi.String("Deployer IP, for the postgresql provider to create databases"), + }) + if err != nil { + return err + } + subnetIDs = subnets.Ids + + dbSubnetGroup, err = rds.NewSubnetGroup(ctx, "postgres-subnet-group", &rds.SubnetGroupArgs{ + SubnetIds: pulumi.ToStringArray(subnets.Ids), + }) + if err != nil { + return err + } + + myIP, err := currentPublicIP() + if err != nil { + return err + } + myIPCidr := fmt.Sprintf("%s/32", myIP) + + dbSecurityGroup, err = ec2.NewSecurityGroup(ctx, "postgres-sg", &ec2.SecurityGroupArgs{ + Description: pulumi.String("Allow Postgres access from within the VPC and the deployers IP"), + VpcId: pulumi.String(vpc.Id), + Ingress: ec2.SecurityGroupIngressArray{ + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(5432), + ToPort: pulumi.Int(5432), + CidrBlocks: pulumi.ToStringArray([]string{vpc.CidrBlock}), + }, + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(5432), + ToPort: pulumi.Int(5432), + CidrBlocks: pulumi.ToStringArray([]string{myIPCidr}), + Description: pulumi.String("Deployer IP, for the postgresql provider to create databases"), + }, }, - }, - Egress: ec2.SecurityGroupEgressArray{ - &ec2.SecurityGroupEgressArgs{ - Protocol: pulumi.String("-1"), - FromPort: pulumi.Int(0), - ToPort: pulumi.Int(0), - CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + Egress: ec2.SecurityGroupEgressArray{ + &ec2.SecurityGroupEgressArgs{ + Protocol: pulumi.String("-1"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(0), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, }, - }, - }) - if err != nil { - return err + }) + if err != nil { + return err + } } // Generated once and kept in the stack state, so it stays stable across deploys @@ -504,7 +574,7 @@ func main() { return err } - db, err := rds.NewInstance(ctx, "postgres-db", &rds.InstanceArgs{ + dbArgs := &rds.InstanceArgs{ Engine: pulumi.String("postgres"), EngineVersion: pulumi.String("16"), InstanceClass: pulumi.String("db.t3.micro"), @@ -514,109 +584,54 @@ func main() { DbName: pulumi.String("appdb"), Username: pulumi.String("postgres"), Password: dbPassword.Result, - DbSubnetGroupName: dbSubnetGroup.Name, - VpcSecurityGroupIds: pulumi.StringArray{dbSecurityGroup.ID()}, PubliclyAccessible: pulumi.Bool(true), MultiAz: pulumi.Bool(false), BackupRetentionPeriod: pulumi.Int(0), DeletionProtection: pulumi.Bool(false), SkipFinalSnapshot: pulumi.Bool(true), ApplyImmediately: pulumi.Bool(true), - }) + } + if localstack { + dbArgs.Port = pulumi.Int(localstackDBPort) + } else { + dbArgs.DbSubnetGroupName = dbSubnetGroup.Name + dbArgs.VpcSecurityGroupIds = pulumi.StringArray{dbSecurityGroup.ID()} + } + + db, err := rds.NewInstance(ctx, "postgres-db", dbArgs) if err != nil { return err } + // RDS terminates TLS; the Postgres process LocalStack runs does not. + sslMode := "require" + if localstack { + sslMode = "disable" + } + pgProvider, err := postgresql.NewProvider(ctx, "postgres-provider", &postgresql.ProviderArgs{ Host: db.Address, Port: db.Port, Username: pulumi.String("postgres"), Password: dbPassword.Result, - Sslmode: pulumi.String("require"), + Sslmode: pulumi.String(sslMode), Superuser: pulumi.Bool(false), }) if err != nil { return err } - cmsPassword, err := newServiceDatabase(ctx, pgProvider, "cms") + cmsPassword, err := newServiceDatabase(ctx, pgProvider, "cms", servicePassword) if err != nil { return err } - discoveryPassword, err := newServiceDatabase(ctx, pgProvider, "discovery") - if err != nil { - return err - } - - // ECS Fargate: one cluster, one internet-facing ALB, one service per app. - cluster, err := ecs.NewCluster(ctx, "app-cluster", nil) - if err != nil { - return err - } - - _, err = ecs.NewClusterCapacityProviders(ctx, "app-cluster-capacity-providers", &ecs.ClusterCapacityProvidersArgs{ - ClusterName: cluster.Name, - CapacityProviders: pulumi.ToStringArray([]string{"FARGATE", "FARGATE_SPOT"}), - DefaultCapacityProviderStrategies: ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArray{ - &ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArgs{ - CapacityProvider: pulumi.String("FARGATE"), - Weight: pulumi.Int(1), - }, - }, - }) - if err != nil { - return err - } - - albSecurityGroup, err := ec2.NewSecurityGroup(ctx, "alb-sg", &ec2.SecurityGroupArgs{ - Description: pulumi.String("Allow inbound HTTP to the app load balancers"), - VpcId: pulumi.String(vpc.Id), - Ingress: ec2.SecurityGroupIngressArray{ - &ec2.SecurityGroupIngressArgs{ - Protocol: pulumi.String("tcp"), - FromPort: pulumi.Int(80), - ToPort: pulumi.Int(80), - CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), - }, - }, - Egress: ec2.SecurityGroupEgressArray{ - &ec2.SecurityGroupEgressArgs{ - Protocol: pulumi.String("-1"), - FromPort: pulumi.Int(0), - ToPort: pulumi.Int(0), - CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), - }, - }, - }) - if err != nil { - return err - } - - serviceSecurityGroup, err := ec2.NewSecurityGroup(ctx, "ecs-service-sg", &ec2.SecurityGroupArgs{ - Description: pulumi.String("Allow the ALB to reach cms and discovery tasks"), - VpcId: pulumi.String(vpc.Id), - Ingress: ec2.SecurityGroupIngressArray{ - &ec2.SecurityGroupIngressArgs{ - Protocol: pulumi.String("tcp"), - FromPort: pulumi.Int(0), - ToPort: pulumi.Int(65535), - SecurityGroups: pulumi.StringArray{albSecurityGroup.ID()}, - }, - }, - Egress: ec2.SecurityGroupEgressArray{ - &ec2.SecurityGroupEgressArgs{ - Protocol: pulumi.String("-1"), - FromPort: pulumi.Int(0), - ToPort: pulumi.Int(0), - CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), - }, - }, - }) + discoveryPassword, err := newServiceDatabase(ctx, pgProvider, "discovery", servicePassword) if err != nil { return err } + // Trust policy shared by every ECS task role. execRoleAssumePolicy, err := json.Marshal(map[string]any{ "Version": "2012-10-17", "Statement": []map[string]any{ @@ -631,21 +646,94 @@ func main() { return err } - execRole, err := iam.NewRole(ctx, "ecs-task-execution-role", &iam.RoleArgs{ - AssumeRolePolicy: pulumi.String(execRoleAssumePolicy), - ManagedPolicyArns: pulumi.ToStringArray([]string{ - "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", - }), - }) - if err != nil { - return err + // ECS Fargate: one cluster, one internet-facing ALB, one service per app. + // docker-compose runs the containers under LocalStack, so none of this + // — cluster, load balancer security groups, or the ECS agent's + // execution role — has a local counterpart. + if !localstack { + cluster, err = ecs.NewCluster(ctx, "app-cluster", nil) + if err != nil { + return err + } + + _, err = ecs.NewClusterCapacityProviders(ctx, "app-cluster-capacity-providers", &ecs.ClusterCapacityProvidersArgs{ + ClusterName: cluster.Name, + CapacityProviders: pulumi.ToStringArray([]string{"FARGATE", "FARGATE_SPOT"}), + DefaultCapacityProviderStrategies: ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArray{ + &ecs.ClusterCapacityProvidersDefaultCapacityProviderStrategyArgs{ + CapacityProvider: pulumi.String("FARGATE"), + Weight: pulumi.Int(1), + }, + }, + }) + if err != nil { + return err + } + + albSecurityGroup, err = ec2.NewSecurityGroup(ctx, "alb-sg", &ec2.SecurityGroupArgs{ + Description: pulumi.String("Allow inbound HTTP to the app load balancers"), + VpcId: pulumi.String(vpcID), + Ingress: ec2.SecurityGroupIngressArray{ + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(80), + ToPort: pulumi.Int(80), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + Egress: ec2.SecurityGroupEgressArray{ + &ec2.SecurityGroupEgressArgs{ + Protocol: pulumi.String("-1"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(0), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + }) + if err != nil { + return err + } + + serviceSecurityGroup, err = ec2.NewSecurityGroup(ctx, "ecs-service-sg", &ec2.SecurityGroupArgs{ + Description: pulumi.String("Allow the ALB to reach cms and discovery tasks"), + VpcId: pulumi.String(vpcID), + Ingress: ec2.SecurityGroupIngressArray{ + &ec2.SecurityGroupIngressArgs{ + Protocol: pulumi.String("tcp"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(65535), + SecurityGroups: pulumi.StringArray{albSecurityGroup.ID()}, + }, + }, + Egress: ec2.SecurityGroupEgressArray{ + &ec2.SecurityGroupEgressArgs{ + Protocol: pulumi.String("-1"), + FromPort: pulumi.Int(0), + ToPort: pulumi.Int(0), + CidrBlocks: pulumi.ToStringArray([]string{"0.0.0.0/0"}), + }, + }, + }) + if err != nil { + return err + } + + execRole, err = iam.NewRole(ctx, "ecs-task-execution-role", &iam.RoleArgs{ + AssumeRolePolicy: pulumi.String(execRoleAssumePolicy), + ManagedPolicyArns: pulumi.ToStringArray([]string{ + "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy", + }), + }) + if err != nil { + return err + } } // Private bucket for raw video uploads (pre-transcode). Kept separate // from encoded-bucket, which is fronted by CloudFront/OAC for public // delivery of finished renditions — raw source video must not be // reachable through that CDN. - rawUploadsBucket, err := s3.NewBucket(ctx, "raw-uploads-bucket", nil) + rawUploadsBucket, err := s3.NewBucket(ctx, "raw-uploads-bucket", rawUploadsBucketArgs) if err != nil { return err } @@ -786,31 +874,43 @@ func main() { return err } - cmsExtraEnv := []envVar{ - {Name: "AWS_REGION", Value: pulumi.String(awsRegion).ToStringOutput()}, - {Name: "S3_BUCKET", Value: rawUploadsBucket.ID().ToStringOutput()}, - {Name: "MEDIACONVERT_INPUT_BUCKET", Value: rawUploadsBucket.ID().ToStringOutput()}, - {Name: "MEDIACONVERT_OUTPUT_BUCKET", Value: bucket.ID().ToStringOutput()}, - {Name: "MEDIACONVERT_ROLE_ARN", Value: mediaConvertRole.Arn}, - } + // The two ECS services and the CI user that deploys them. Under + // LocalStack the equivalent of all this is the cms service in + // docker-compose.yml, which reads the same env vars from .env. + if !localstack { + cmsExtraEnv := []envVar{ + {Name: "AWS_REGION", Value: pulumi.String(awsRegion).ToStringOutput()}, + {Name: "S3_BUCKET", Value: rawUploadsBucket.ID().ToStringOutput()}, + {Name: "MEDIACONVERT_INPUT_BUCKET", Value: rawUploadsBucket.ID().ToStringOutput()}, + {Name: "MEDIACONVERT_OUTPUT_BUCKET", Value: bucket.ID().ToStringOutput()}, + {Name: "MEDIACONVERT_ROLE_ARN", Value: mediaConvertRole.Arn}, + } - cmsRepo, cmsService, cmsAlb, err := deployFargateService(ctx, "cms", 8081, - cluster, execRole, cmsTaskRole, cmsExtraEnv, vpc.Id, subnets.Ids, albSecurityGroup, serviceSecurityGroup, - db.Address, db.Port, cmsPassword, true) - if err != nil { - return err + cmsRepo, cmsService, cmsAlb, err = deployFargateService(ctx, "cms", 8081, + cluster, execRole, cmsTaskRole, cmsExtraEnv, vpcID, subnetIDs, albSecurityGroup, serviceSecurityGroup, + db.Address, db.Port, cmsPassword, true) + if err != nil { + return err + } } // The browser PUTs directly to raw-uploads-bucket using the presigned // URL from PresignVideoUpload, so the bucket (not the cms app) is // what needs to answer the CORS preflight — scoped to the cms - // origin the upload page is actually served from. + // origin the upload page is actually served from. Under LocalStack + // there is no ALB to name, and the caller is whatever the developer + // happens to be running. + var corsOrigins pulumi.StringArrayInput = pulumi.ToStringArray([]string{"*"}) + if !localstack { + corsOrigins = pulumi.StringArray{pulumi.Sprintf("http://%s", cmsAlb.DnsName)} + } + _, err = s3.NewBucketCorsConfigurationV2(ctx, "raw-uploads-bucket-cors", &s3.BucketCorsConfigurationV2Args{ Bucket: rawUploadsBucket.ID(), CorsRules: s3.BucketCorsConfigurationV2CorsRuleArray{ &s3.BucketCorsConfigurationV2CorsRuleArgs{ AllowedMethods: pulumi.ToStringArray([]string{"PUT"}), - AllowedOrigins: pulumi.StringArray{pulumi.Sprintf("http://%s", cmsAlb.DnsName)}, + AllowedOrigins: corsOrigins, AllowedHeaders: pulumi.ToStringArray([]string{"*"}), ExposeHeaders: pulumi.ToStringArray([]string{"ETag"}), MaxAgeSeconds: pulumi.Int(3000), @@ -821,90 +921,99 @@ func main() { return err } - discoveryRepo, discoveryService, discoveryAlb, err := deployFargateService(ctx, "discovery", 8080, - cluster, execRole, nil, nil, vpc.Id, subnets.Ids, albSecurityGroup, serviceSecurityGroup, - db.Address, db.Port, discoveryPassword, false) - if err != nil { - return err - } + if !localstack { + discoveryRepo, discoveryService, discoveryAlb, err = deployFargateService(ctx, "discovery", 8080, + cluster, execRole, nil, nil, vpcID, subnetIDs, albSecurityGroup, serviceSecurityGroup, + db.Address, db.Port, discoveryPassword, false) + if err != nil { + return err + } - // Gitea Actions CI: one IAM user, scoped to just what the build/deploy - // pipeline needs — push images to the two ECR repos and force a new - // deployment on the two ECS services. Credentials are exported as - // stack secrets for the pipeline to consume (e.g. as Gitea Actions - // secrets); nothing broader like ecr:*, ecs:*, or task-def changes. - giteaCIPolicyDocument := pulumi.All(cmsRepo.Arn, discoveryRepo.Arn, cmsService.Arn, discoveryService.Arn).ApplyT( - func(args []any) (string, error) { - cmsRepoArn := args[0].(string) - discoveryRepoArn := args[1].(string) - cmsServiceArn := args[2].(string) - discoveryServiceArn := args[3].(string) + // Gitea Actions CI: one IAM user, scoped to just what the build/deploy + // pipeline needs — push images to the two ECR repos and force a new + // deployment on the two ECS services. Credentials are exported as + // stack secrets for the pipeline to consume (e.g. as Gitea Actions + // secrets); nothing broader like ecr:*, ecs:*, or task-def changes. + giteaCIPolicyDocument := pulumi.All(cmsRepo.Arn, discoveryRepo.Arn, cmsService.Arn, discoveryService.Arn).ApplyT( + func(args []any) (string, error) { + cmsRepoArn := args[0].(string) + discoveryRepoArn := args[1].(string) + cmsServiceArn := args[2].(string) + discoveryServiceArn := args[3].(string) - doc := map[string]any{ - "Version": "2012-10-17", - "Statement": []map[string]any{ - { - "Sid": "ECRAuth", - "Effect": "Allow", - "Action": "ecr:GetAuthorizationToken", - "Resource": "*", - }, - { - "Sid": "ECRPush", - "Effect": "Allow", - "Action": []string{ - "ecr:BatchCheckLayerAvailability", - "ecr:BatchGetImage", - "ecr:PutImage", - "ecr:InitiateLayerUpload", - "ecr:UploadLayerPart", - "ecr:CompleteLayerUpload", + doc := map[string]any{ + "Version": "2012-10-17", + "Statement": []map[string]any{ + { + "Sid": "ECRAuth", + "Effect": "Allow", + "Action": "ecr:GetAuthorizationToken", + "Resource": "*", }, - "Resource": []string{cmsRepoArn, discoveryRepoArn}, - }, - { - "Sid": "ECSDeploy", - "Effect": "Allow", - "Action": []string{ - "ecs:UpdateService", - "ecs:DescribeServices", + { + "Sid": "ECRPush", + "Effect": "Allow", + "Action": []string{ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:PutImage", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + }, + "Resource": []string{cmsRepoArn, discoveryRepoArn}, + }, + { + "Sid": "ECSDeploy", + "Effect": "Allow", + "Action": []string{ + "ecs:UpdateService", + "ecs:DescribeServices", + }, + "Resource": []string{cmsServiceArn, discoveryServiceArn}, }, - "Resource": []string{cmsServiceArn, discoveryServiceArn}, }, - }, - } - b, err := json.Marshal(doc) - return string(b), err - }, - ).(pulumi.StringOutput) + } + b, err := json.Marshal(doc) + return string(b), err + }, + ).(pulumi.StringOutput) - giteaCIUser, err := iam.NewUser(ctx, "gitea-ci-user", &iam.UserArgs{ - Name: pulumi.String("gitea-ci"), - }) - if err != nil { - return err - } + giteaCIUser, err := iam.NewUser(ctx, "gitea-ci-user", &iam.UserArgs{ + Name: pulumi.String("gitea-ci"), + }) + if err != nil { + return err + } - _, err = iam.NewUserPolicy(ctx, "gitea-ci-policy", &iam.UserPolicyArgs{ - User: giteaCIUser.Name, - Policy: giteaCIPolicyDocument, - }) - if err != nil { - return err + _, err = iam.NewUserPolicy(ctx, "gitea-ci-policy", &iam.UserPolicyArgs{ + User: giteaCIUser.Name, + Policy: giteaCIPolicyDocument, + }) + if err != nil { + return err + } } // Export the name of the bucket ctx.Export("bucketName", bucket.ID()) - ctx.Export("cdnDomainName", distribution.DomainName) + ctx.Export("rawUploadsBucketName", rawUploadsBucket.ID()) ctx.Export("dbEndpoint", db.Endpoint) ctx.Export("dbPassword", dbPassword.Result) - ctx.Export("cmsUrl", pulumi.Sprintf("http://%s", cmsAlb.DnsName)) - ctx.Export("discoveryUrl", pulumi.Sprintf("http://%s", discoveryAlb.DnsName)) - ctx.Export("cmsRepoUrl", cmsRepo.RepositoryUrl) - ctx.Export("discoveryRepoUrl", discoveryRepo.RepositoryUrl) - ctx.Export("ecsClusterArn", cluster.Arn) - ctx.Export("cmsServiceArn", cmsService.Arn) - ctx.Export("discoveryServiceArn", discoveryService.Arn) + ctx.Export("mediaConvertRoleArn", mediaConvertRole.Arn) + + // Everything below exists only on the AWS stacks. The CI workflows read + // ecsClusterArn and cmsServiceArn from here. + if !localstack { + ctx.Export("cdnDomainName", distribution.DomainName) + ctx.Export("cmsUrl", pulumi.Sprintf("http://%s", cmsAlb.DnsName)) + ctx.Export("discoveryUrl", pulumi.Sprintf("http://%s", discoveryAlb.DnsName)) + ctx.Export("cmsRepoUrl", cmsRepo.RepositoryUrl) + ctx.Export("discoveryRepoUrl", discoveryRepo.RepositoryUrl) + ctx.Export("ecsClusterArn", cluster.Arn) + ctx.Export("cmsServiceArn", cmsService.Arn) + ctx.Export("discoveryServiceArn", discoveryService.Arn) + } return nil }) }