From d36565ab211b9bed93fd6af1ce3c34aa57c92c14 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 9 Apr 2026 19:57:33 -0400 Subject: [PATCH 01/29] netwwork, iam and rds module --- .../environments/sandbox/.terraform.lock.hcl | 25 ++++++ infrastructure/environments/sandbox/main.tf | 83 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 infrastructure/environments/sandbox/.terraform.lock.hcl create mode 100644 infrastructure/environments/sandbox/main.tf diff --git a/infrastructure/environments/sandbox/.terraform.lock.hcl b/infrastructure/environments/sandbox/.terraform.lock.hcl new file mode 100644 index 0000000000..cdc1668d41 --- /dev/null +++ b/infrastructure/environments/sandbox/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf new file mode 100644 index 0000000000..ddccf7d6dc --- /dev/null +++ b/infrastructure/environments/sandbox/main.tf @@ -0,0 +1,83 @@ +# Sandbox Environment - Main Configuration +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" { + bucket = "finishline-terraform-state" + key = "sandbox/terraform.tfstate" + region = "us-east-1" + encrypt = true + dynamodb_table = "finishline-terraform-locks" + } +} + +provider "aws" { + region = "us-east-2" + + default_tags { + tags = { + Project = "finishline" + Environment = "sandbox" + ManagedBy = "Terraform" + } + } +} + +############# +# Network Module +############# +module "network" { + source = "../../modules/network" + + project_name = "finishline" + environment = "sandbox" + aws_region = "us-east-2" + vpc_cidr = "10.1.0.0/16" +} + +############# +# IAM Module +############# +module "iam" { + source = "../../modules/iam" + + project_name = "finishline" + environment = "sandbox" + aws_region = "us-east-2" + create_cloudfront_oai = false +} + +############# +# RDS Module +############# +module "rds" { + source = "../../modules/rds" + + project_name = "finishline" + environment = "sandbox" + + # Network + db_subnet_group_name = module.network.db_subnet_group_name + security_group_id = module.network.rds_security_group_id + + # Instance config - downsized for sandbox + instance_class = "db.t3.micro" + allocated_storage = 20 + + # Sandbox-specific: no backups, no deletion protection + backup_retention_period = 0 + deletion_protection = false + alarm_actions = [] + + # Credentials + database_name = "finishline" + master_username = "finishline" + master_password = "changeme123!" +} \ No newline at end of file From 8968156b45d3816ed68c4e5a7a7894be6d6d5b91 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 11 Jun 2026 16:19:19 -0400 Subject: [PATCH 02/29] fix RDS, add variables and outputs, add EB module --- infrastructure/environments/sandbox/main.tf | 72 ++++++++++- .../environments/sandbox/outputs.tf | 45 +++++++ .../environments/sandbox/variables.tf | 114 ++++++++++++++++++ infrastructure/modules/rds/main.tf | 5 +- infrastructure/modules/rds/variables.tf | 12 ++ 5 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 infrastructure/environments/sandbox/outputs.tf create mode 100644 infrastructure/environments/sandbox/variables.tf diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index ddccf7d6dc..d62af02f44 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -71,13 +71,81 @@ module "rds" { instance_class = "db.t3.micro" allocated_storage = 20 - # Sandbox-specific: no backups, no deletion protection + # Sandbox-specific: no backups, no deletion protection, no final snapshot backup_retention_period = 0 deletion_protection = false + skip_final_snapshot = true alarm_actions = [] + # Restore from a prod snapshot when provided (passed in by CI/CD pipeline) + snapshot_identifier = var.snapshot_identifier + # Credentials database_name = "finishline" master_username = "finishline" - master_password = "changeme123!" + master_password = var.db_master_password +} + +############# +# Elastic Beanstalk Module +############# +module "elasticbeanstalk" { + source = "../../modules/elasticbeanstalk" + + project_name = "finishline" + environment = "sandbox" + vpc_id = module.network.vpc_id + subnet_ids = module.network.public_subnet_ids + elb_subnet_ids = module.network.public_subnet_ids + + # IAM + instance_profile_name = module.iam.eb_ec2_instance_profile_name + eb_service_role_name = module.iam.eb_service_role_name + eb_service_role_arn = module.iam.eb_service_role_arn + + # Security + instance_security_group_id = module.network.eb_instance_security_group_id + alb_security_group_id = module.network.alb_security_group_id + + # Sandbox-specific: single instance, fast deploys, no HTTPS + min_instance_count = 1 + max_instance_count = 1 + deployment_policy = "AllAtOnce" + enable_https = false + health_check_path = "/health" + log_retention_days = 7 + + environment_variables = { + DATABASE_URL = module.rds.database_url + + SESSION_SECRET = var.session_secret + ENCRYPTION_KEY = var.encryption_key + GOOGLE_CLIENT_SECRET = var.google_client_secret + DRIVE_REFRESH_TOKEN = var.drive_refresh_token + CALENDAR_REFRESH_TOKEN = var.calendar_refresh_token + SLACK_BOT_TOKEN = var.slack_bot_token + SLACK_TOKEN_SECRET = var.slack_token_secret + SLACK_SIGNING_SECRET = var.slack_signing_secret + NOTIFICATION_ENDPOINT_SECRET = var.notification_endpoint_secret + + LOG_LEVEL = "info" + GOOGLE_CLIENT_ID = var.google_client_id + REACT_APP_GOOGLE_AUTH_CLIENT_ID = var.google_client_id + GOOGLE_DRIVE_FOLDER_ID = var.google_drive_folder_id + SLACK_ID = var.slack_id + USER_EMAIL = var.user_email + ADMIN_USER_ID = var.admin_user_id + } +} + +############# +# CloudWatch Log Group (skip full monitoring module — no dashboards or alarms needed for sandbox) +############# +resource "aws_cloudwatch_log_group" "eb_logs" { + name = "/aws/elasticbeanstalk/finishline-sandbox" + retention_in_days = 7 + + tags = { + Name = "finishline-sandbox-eb-logs" + } } \ No newline at end of file diff --git a/infrastructure/environments/sandbox/outputs.tf b/infrastructure/environments/sandbox/outputs.tf new file mode 100644 index 0000000000..e30ba9ffa0 --- /dev/null +++ b/infrastructure/environments/sandbox/outputs.tf @@ -0,0 +1,45 @@ +# Sandbox Environment Outputs +# These are consumed by the CI/CD pipeline after terraform apply. + +##################### +# Network Outputs +##################### + +output "vpc_id" { + description = "VPC ID" + value = module.network.vpc_id +} + +##################### +# RDS Outputs +##################### + +output "rds_endpoint" { + description = "RDS endpoint (host:port)" + value = module.rds.db_instance_endpoint +} + +output "rds_address" { + description = "RDS hostname" + value = module.rds.db_instance_address +} + +output "database_url" { + description = "Full database connection URL (used by CI/CD to write the sandbox DATABASE_URL secret)" + value = module.rds.database_url + sensitive = true +} + +##################### +# Elastic Beanstalk Outputs +##################### + +output "eb_environment_url" { + description = "URL of the sandbox EB environment (used by Amplify to point the frontend at the sandbox backend)" + value = module.elasticbeanstalk.environment_endpoint_url +} + +output "eb_cname" { + description = "Raw CNAME of the sandbox EB environment" + value = module.elasticbeanstalk.environment_cname +} diff --git a/infrastructure/environments/sandbox/variables.tf b/infrastructure/environments/sandbox/variables.tf new file mode 100644 index 0000000000..e94825e996 --- /dev/null +++ b/infrastructure/environments/sandbox/variables.tf @@ -0,0 +1,114 @@ +# Sandbox Environment Variables +# Most sandbox config is hardcoded (us-east-2, single instance, etc.) +# Only values that the CI/CD pipeline injects at runtime are variables. + +##################### +# RDS Variables +##################### + +variable "db_master_password" { + description = "Master password for the sandbox database" + type = string + sensitive = true + # Set via: export TF_VAR_db_master_password="..." + # In CI/CD: generated once and stored in GitHub Actions secrets +} + +variable "snapshot_identifier" { + description = "RDS snapshot ID to restore from (taken from prod by the CI/CD pipeline before spin-up)" + type = string + default = null + # When null, a fresh empty database is created instead +} + +##################### +# Secrets (injected by CI/CD from prod Secrets Manager at pipeline run time) +##################### + +variable "session_secret" { + description = "Secret key for application session management" + type = string + sensitive = true +} + +variable "encryption_key" { + description = "Application encryption key" + type = string + sensitive = true +} + +variable "google_client_secret" { + description = "Google OAuth client secret" + type = string + sensitive = true +} + +variable "drive_refresh_token" { + description = "Google Drive refresh token" + type = string + sensitive = true +} + +variable "calendar_refresh_token" { + description = "Google Calendar refresh token" + type = string + sensitive = true +} + +variable "slack_bot_token" { + description = "Slack bot token" + type = string + sensitive = true +} + +variable "slack_token_secret" { + description = "Slack OAuth token secret" + type = string + sensitive = true +} + +variable "slack_signing_secret" { + description = "Slack signing secret for request verification" + type = string + sensitive = true +} + +variable "notification_endpoint_secret" { + description = "Secret for notification endpoint authentication" + type = string + sensitive = true +} + +##################### +# Non-secret application config +##################### + +variable "google_client_id" { + description = "Google OAuth client ID (public)" + type = string + default = "" +} + +variable "google_drive_folder_id" { + description = "Google Drive folder ID" + type = string + default = "" +} + +variable "slack_id" { + description = "Slack app ID" + type = string + default = "" +} + +variable "user_email" { + description = "Primary email address" + type = string + default = "" +} + +variable "admin_user_id" { + description = "Admin user ID" + type = string + default = "" +} diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 7cb733aadd..1999666c93 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -29,8 +29,9 @@ resource "aws_db_instance" "main" { backup_retention_period = var.backup_retention_period backup_window = var.backup_window maintenance_window = var.maintenance_window - skip_final_snapshot = false - final_snapshot_identifier = "${var.project_name}-${var.environment}-final-snapshot" + skip_final_snapshot = var.skip_final_snapshot + final_snapshot_identifier = var.skip_final_snapshot ? null : "${var.project_name}-${var.environment}-final-snapshot" + snapshot_identifier = var.snapshot_identifier # Multi-AZ configuration multi_az = var.multi_az diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index 8b0f3470d5..d2f770ccd9 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -108,3 +108,15 @@ variable "alarm_actions" { type = list(string) default = [] } + +variable "skip_final_snapshot" { + description = "Skip final snapshot on destroy (set true for ephemeral environments)" + type = bool + default = false +} + +variable "snapshot_identifier" { + description = "Snapshot ID to restore from on creation (leave null for a fresh database)" + type = string + default = null +} From ddfbc82a5f5350a884020b884b3cf1393cac16f7 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 11 Jun 2026 16:29:25 -0400 Subject: [PATCH 03/29] CI/CD --- infrastructure/bootstrap/main.tf | 347 +++++++++++++++++++++++++- infrastructure/bootstrap/outputs.tf | 5 + infrastructure/bootstrap/variables.tf | 6 + 3 files changed, 357 insertions(+), 1 deletion(-) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index a482c9645e..4ef905d8d7 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -166,7 +166,7 @@ resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { filter {} expiration { - days = 90 + days = 90 } noncurrent_version_expiration { @@ -174,3 +174,348 @@ resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { } } } + +############# +# GitHub Actions OIDC Provider +# Allows GitHub Actions workflows to assume AWS roles without long-lived credentials. +# This is a global IAM resource — only one per account regardless of region. +############# +resource "aws_iam_openid_connect_provider" "github_actions" { + url = "https://token.actions.githubusercontent.com" + + client_id_list = ["sts.amazonaws.com"] + + # Stable thumbprint for token.actions.githubusercontent.com + thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] + + tags = { + Name = "github-actions-oidc" + Purpose = "Allows GitHub Actions to assume AWS roles via OIDC" + } +} + +############# +# CI/CD Role — assumed by GitHub Actions to spin up/tear down the sandbox +############# +resource "aws_iam_role" "cicd" { + name = "finishline-cicd" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = aws_iam_openid_connect_provider.github_actions.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringLike = { + "token.actions.githubusercontent.com:sub" = "repo:${var.github_repo}:*" + } + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + } + } + ] + }) + + tags = { + Name = "finishline-cicd" + Purpose = "GitHub Actions sandbox spin-up and tear-down" + } +} + +# Terraform state access (scoped to sandbox key only) +resource "aws_iam_role_policy" "cicd_terraform_state" { + name = "terraform-state-access" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "StateReadWrite" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ] + Resource = [ + "arn:aws:s3:::${var.state_bucket_name}", + "arn:aws:s3:::${var.state_bucket_name}/sandbox/*" + ] + }, + { + Sid = "StateLocking" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem" + ] + Resource = "arn:aws:dynamodb:${var.aws_region}:*:table/${var.locks_table_name}" + } + ] + }) +} + +# Network — VPC, subnets, IGW, route tables, security groups (all tagged sandbox) +resource "aws_iam_role_policy" "cicd_network" { + name = "sandbox-network" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EC2NetworkFull" + Effect = "Allow" + Action = [ + "ec2:CreateVpc", + "ec2:DeleteVpc", + "ec2:ModifyVpcAttribute", + "ec2:DescribeVpcs", + "ec2:CreateSubnet", + "ec2:DeleteSubnet", + "ec2:ModifySubnetAttribute", + "ec2:DescribeSubnets", + "ec2:CreateInternetGateway", + "ec2:DeleteInternetGateway", + "ec2:AttachInternetGateway", + "ec2:DetachInternetGateway", + "ec2:DescribeInternetGateways", + "ec2:CreateRouteTable", + "ec2:DeleteRouteTable", + "ec2:CreateRoute", + "ec2:DeleteRoute", + "ec2:AssociateRouteTable", + "ec2:DisassociateRouteTable", + "ec2:DescribeRouteTables", + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:RevokeSecurityGroupEgress", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSecurityGroupRules", + "ec2:CreateTags", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeAccountAttributes" + ] + Resource = "*" + } + ] + }) +} + +# RDS — create/delete instances and snapshots (scoped to sandbox identifier prefix) +resource "aws_iam_role_policy" "cicd_rds" { + name = "sandbox-rds" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SandboxRDSManage" + Effect = "Allow" + Action = [ + "rds:CreateDBInstance", + "rds:DeleteDBInstance", + "rds:ModifyDBInstance", + "rds:DescribeDBInstances", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBSubnetGroup", + "rds:DescribeDBSubnetGroups", + "rds:AddTagsToResource", + "rds:ListTagsForResource", + "rds:DescribeDBParameterGroups" + ] + Resource = "*" + Condition = { + StringLike = { + "rds:db-tag/Environment" = "sandbox" + } + } + }, + { + # DescribeDBInstances and subnet group ops don't support tag conditions + Sid = "RDSDescribeGlobal" + Effect = "Allow" + Action = [ + "rds:DescribeDBInstances", + "rds:DescribeDBSubnetGroups", + "rds:DescribeDBSnapshots", + "rds:DescribeDBEngineVersions" + ] + Resource = "*" + }, + { + Sid = "ProdSnapshotRead" + Effect = "Allow" + Action = [ + "rds:CreateDBSnapshot", + "rds:DescribeDBSnapshots", + "rds:RestoreDBInstanceFromDBSnapshot", + "rds:CopyDBSnapshot" + ] + Resource = "*" + } + ] + }) +} + +# Elastic Beanstalk — full access scoped to finishline-sandbox resources +resource "aws_iam_role_policy" "cicd_elasticbeanstalk" { + name = "sandbox-elasticbeanstalk" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EBManage" + Effect = "Allow" + Action = [ + "elasticbeanstalk:CreateApplication", + "elasticbeanstalk:DeleteApplication", + "elasticbeanstalk:DescribeApplications", + "elasticbeanstalk:CreateEnvironment", + "elasticbeanstalk:DeleteEnvironment", + "elasticbeanstalk:DescribeEnvironments", + "elasticbeanstalk:DescribeEnvironmentResources", + "elasticbeanstalk:UpdateEnvironment", + "elasticbeanstalk:TerminateEnvironment", + "elasticbeanstalk:CreateApplicationVersion", + "elasticbeanstalk:DeleteApplicationVersion", + "elasticbeanstalk:DescribeApplicationVersions", + "elasticbeanstalk:DescribeConfigurationSettings", + "elasticbeanstalk:DescribeConfigurationOptions", + "elasticbeanstalk:ValidateConfigurationSettings", + "elasticbeanstalk:ListTagsForResource", + "elasticbeanstalk:AddTags", + "elasticbeanstalk:DescribeEvents" + ] + Resource = "*" + }, + { + # EB needs to manage ELB/ASG/EC2 resources on your behalf + Sid = "EBSupportingServices" + Effect = "Allow" + Action = [ + "autoscaling:*", + "elasticloadbalancing:*", + "cloudwatch:PutMetricAlarm", + "cloudwatch:DeleteAlarms", + "cloudwatch:DescribeAlarms", + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket", + "s3:DeleteObject" + ] + Resource = "*" + } + ] + }) +} + +# IAM — create/delete the sandbox EB roles (name-scoped to finishline-sandbox-*) +resource "aws_iam_role_policy" "cicd_iam" { + name = "sandbox-iam" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SandboxRolesManage" + Effect = "Allow" + Action = [ + "iam:CreateRole", + "iam:DeleteRole", + "iam:GetRole", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:GetRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:CreateInstanceProfile", + "iam:DeleteInstanceProfile", + "iam:GetInstanceProfile", + "iam:AddRoleToInstanceProfile", + "iam:RemoveRoleFromInstanceProfile", + "iam:TagRole", + "iam:UntagRole", + "iam:ListInstanceProfilesForRole" + ] + Resource = [ + "arn:aws:iam::*:role/finishline-sandbox-*", + "arn:aws:iam::*:instance-profile/finishline-sandbox-*" + ] + }, + { + Sid = "PassRoleToEB" + Effect = "Allow" + Action = "iam:PassRole" + Resource = "arn:aws:iam::*:role/finishline-sandbox-*" + } + ] + }) +} + +# CloudWatch — log groups for sandbox +resource "aws_iam_role_policy" "cicd_cloudwatch" { + name = "sandbox-cloudwatch" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "LogGroupManage" + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:DescribeLogGroups", + "logs:PutRetentionPolicy", + "logs:ListTagsLogGroup", + "logs:TagLogGroup", + "logs:UntagLogGroup", + "logs:ListTagsForResource", + "logs:TagResource", + "logs:UntagResource" + ] + Resource = "arn:aws:logs:*:*:log-group:/aws/elasticbeanstalk/finishline-sandbox*" + } + ] + }) +} + +# Secrets Manager — read prod secrets, write sandbox DATABASE_URL +resource "aws_iam_role_policy" "cicd_secrets" { + name = "sandbox-secrets" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadProdSecrets" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "arn:aws:secretsmanager:*:*:secret:finishline/production/*" + } + ] + }) +} diff --git a/infrastructure/bootstrap/outputs.tf b/infrastructure/bootstrap/outputs.tf index 3cf708b582..1c40aaa356 100644 --- a/infrastructure/bootstrap/outputs.tf +++ b/infrastructure/bootstrap/outputs.tf @@ -30,6 +30,11 @@ output "eb_versions_bucket_arn" { value = aws_s3_bucket.eb_versions.arn } +output "cicd_role_arn" { + description = "ARN of the CI/CD role — add this as the AWS_CICD_ROLE_ARN GitHub Actions secret" + value = aws_iam_role.cicd.arn +} + output "next_steps" { description = "Instructions for next steps" value = <<-EOT diff --git a/infrastructure/bootstrap/variables.tf b/infrastructure/bootstrap/variables.tf index da098bc007..7e8265f432 100644 --- a/infrastructure/bootstrap/variables.tf +++ b/infrastructure/bootstrap/variables.tf @@ -23,3 +23,9 @@ variable "eb_versions_bucket_name" { type = string default = "finishline-eb-versions" } + +variable "github_repo" { + description = "GitHub repo in org/name format — used to scope the OIDC trust policy" + type = string + default = "Northeastern-Electric-Racing/FinishLine" +} From ac6744320728b868fe9fceb54bd89d5c46a1273c Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 11 Jun 2026 18:22:51 -0400 Subject: [PATCH 04/29] spin up, down and cleanup --- .github/workflows/sandbox-down.yml | 83 +++++++ .github/workflows/sandbox-up.yml | 229 ++++++++++++++++++ infrastructure/environments/sandbox/main.tf | 1 + .../modules/elasticbeanstalk/main.tf | 9 + .../modules/elasticbeanstalk/variables.tf | 6 + infrastructure/scripts/cleanup-sandbox.sh | 218 +++++++++++++++++ 6 files changed, 546 insertions(+) create mode 100644 .github/workflows/sandbox-down.yml create mode 100644 .github/workflows/sandbox-up.yml create mode 100755 infrastructure/scripts/cleanup-sandbox.sh diff --git a/.github/workflows/sandbox-down.yml b/.github/workflows/sandbox-down.yml new file mode 100644 index 0000000000..426d133a12 --- /dev/null +++ b/.github/workflows/sandbox-down.yml @@ -0,0 +1,83 @@ +name: Sandbox Tear-Down + +on: + pull_request: + types: [closed] + branches: + - multitenancy + +# Share the concurrency group with sandbox-up so they can't run simultaneously. +concurrency: + group: sandbox + cancel-in-progress: false + +permissions: + contents: read + +jobs: + tear-down: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event.pull_request.merged == true + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Check if sandbox exists + id: check + run: | + STATUS=$(aws elasticbeanstalk describe-environments \ + --environment-names finishline-sandbox-env \ + --region us-east-2 \ + --query "Environments[0].Status" \ + --output text 2>/dev/null || echo "None") + + if [ "$STATUS" = "None" ] || [ "$STATUS" = "" ] || [ "$STATUS" = "Terminated" ]; then + echo "No active sandbox found, nothing to tear down." + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "Sandbox found with status: $STATUS, proceeding with teardown." + echo "exists=true" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Terraform + if: steps.check.outputs.exists == 'true' + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~1.0" + terraform_wrapper: false + + - name: Terraform init + if: steps.check.outputs.exists == 'true' + working-directory: infrastructure/environments/sandbox + run: terraform init + + - name: Terraform destroy + if: steps.check.outputs.exists == 'true' + working-directory: infrastructure/environments/sandbox + env: + # Terraform requires all required variables to have values even for destroy. + # The actual values are irrelevant since destroy only reads state. + TF_VAR_db_master_password: "unused" + TF_VAR_session_secret: "unused" + TF_VAR_encryption_key: "unused" + TF_VAR_google_client_secret: "unused" + TF_VAR_drive_refresh_token: "unused" + TF_VAR_calendar_refresh_token: "unused" + TF_VAR_slack_bot_token: "unused" + TF_VAR_slack_token_secret: "unused" + TF_VAR_slack_signing_secret: "unused" + TF_VAR_notification_endpoint_secret: "unused" + run: terraform destroy -auto-approve + + - name: Tag-based cleanup safety net + if: steps.check.outputs.exists == 'true' + run: bash infrastructure/scripts/cleanup-sandbox.sh diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml new file mode 100644 index 0000000000..055d9299ec --- /dev/null +++ b/.github/workflows/sandbox-up.yml @@ -0,0 +1,229 @@ +name: Sandbox Spin-Up + +on: + workflow_dispatch: + +# Only one sandbox may exist at a time. +concurrency: + group: sandbox + cancel-in-progress: false + +permissions: + contents: read + +jobs: + spin-up: + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: sandbox + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Fail fast if sandbox already exists + run: | + STATUS=$(aws elasticbeanstalk describe-environments \ + --environment-names finishline-sandbox-env \ + --region us-east-2 \ + --query "Environments[0].Status" \ + --output text 2>/dev/null || echo "None") + + if [ "$STATUS" != "None" ] && [ "$STATUS" != "" ] && [ "$STATUS" != "Terminated" ]; then + echo "Sandbox already exists with status: $STATUS" + echo "Tear it down first with the sandbox-down workflow." + exit 1 + fi + + - name: Take prod RDS snapshot + id: snapshot + run: | + SNAPSHOT_ID="finishline-prod-presandbox-$(date +%Y%m%d%H%M%S)" + + aws rds create-db-snapshot \ + --db-instance-identifier finishline-production-db \ + --db-snapshot-identifier "$SNAPSHOT_ID" \ + --region us-east-1 + + echo "Waiting for snapshot to become available (~5 min)..." + aws rds wait db-snapshot-available \ + --db-snapshot-identifier "$SNAPSHOT_ID" \ + --region us-east-1 + + echo "us_east_1_snapshot_id=$SNAPSHOT_ID" >> "$GITHUB_OUTPUT" + + - name: Copy snapshot to us-east-2 + id: snapshot_copy + run: | + SOURCE_SNAPSHOT="${{ steps.snapshot.outputs.us_east_1_snapshot_id }}" + COPY_ID="${SOURCE_SNAPSHOT}-us-east-2" + SOURCE_ARN=$(aws rds describe-db-snapshots \ + --db-snapshot-identifier "$SOURCE_SNAPSHOT" \ + --region us-east-1 \ + --query "DBSnapshots[0].DBSnapshotArn" \ + --output text) + + aws rds copy-db-snapshot \ + --source-db-snapshot-identifier "$SOURCE_ARN" \ + --target-db-snapshot-identifier "$COPY_ID" \ + --region us-east-2 + + echo "Waiting for snapshot copy to become available (~5 min)..." + aws rds wait db-snapshot-available \ + --db-snapshot-identifier "$COPY_ID" \ + --region us-east-2 + + echo "snapshot_id=$COPY_ID" >> "$GITHUB_OUTPUT" + + - name: Pull prod secrets from Secrets Manager + run: | + fetch() { + aws secretsmanager get-secret-value \ + --secret-id "finishline/production/$1" \ + --region us-east-1 \ + --query SecretString \ + --output text + } + + SESSION_SECRET=$(fetch session-secret) + ENCRYPTION_KEY=$(fetch encryption-key) + GOOGLE_CLIENT_SECRET=$(fetch google-client-secret) + DRIVE_REFRESH_TOKEN=$(fetch drive-refresh-token) + CALENDAR_REFRESH_TOKEN=$(fetch calendar-refresh-token) + SLACK_BOT_TOKEN=$(fetch slack-bot-token) + SLACK_TOKEN_SECRET=$(fetch slack-token-secret) + SLACK_SIGNING_SECRET=$(fetch slack-signing-secret) + NOTIFICATION_ENDPOINT_SECRET=$(fetch notification-endpoint-secret) + + echo "::add-mask::$SESSION_SECRET" + echo "::add-mask::$ENCRYPTION_KEY" + echo "::add-mask::$GOOGLE_CLIENT_SECRET" + echo "::add-mask::$DRIVE_REFRESH_TOKEN" + echo "::add-mask::$CALENDAR_REFRESH_TOKEN" + echo "::add-mask::$SLACK_BOT_TOKEN" + echo "::add-mask::$SLACK_TOKEN_SECRET" + echo "::add-mask::$SLACK_SIGNING_SECRET" + echo "::add-mask::$NOTIFICATION_ENDPOINT_SECRET" + + { + echo "TF_VAR_session_secret=$SESSION_SECRET" + echo "TF_VAR_encryption_key=$ENCRYPTION_KEY" + echo "TF_VAR_google_client_secret=$GOOGLE_CLIENT_SECRET" + echo "TF_VAR_drive_refresh_token=$DRIVE_REFRESH_TOKEN" + echo "TF_VAR_calendar_refresh_token=$CALENDAR_REFRESH_TOKEN" + echo "TF_VAR_slack_bot_token=$SLACK_BOT_TOKEN" + echo "TF_VAR_slack_token_secret=$SLACK_TOKEN_SECRET" + echo "TF_VAR_slack_signing_secret=$SLACK_SIGNING_SECRET" + echo "TF_VAR_notification_endpoint_secret=$NOTIFICATION_ENDPOINT_SECRET" + } >> "$GITHUB_ENV" + + - name: Pull non-secret config from prod EB environment + run: | + # These public values are already set on the prod EB environment — + # no developer setup needed, always in sync with prod. + eb_var() { + aws elasticbeanstalk describe-configuration-settings \ + --application-name finishline-production \ + --environment-name finishline-production-env \ + --region us-east-1 \ + --query "ConfigurationSettings[0].OptionSettings[?Namespace=='aws:elasticbeanstalk:application:environment'&&OptionName=='$1'].Value" \ + --output text + } + + { + echo "TF_VAR_google_client_id=$(eb_var GOOGLE_CLIENT_ID)" + echo "TF_VAR_google_drive_folder_id=$(eb_var GOOGLE_DRIVE_FOLDER_ID)" + echo "TF_VAR_slack_id=$(eb_var SLACK_ID)" + echo "TF_VAR_user_email=$(eb_var USER_EMAIL)" + echo "TF_VAR_admin_user_id=$(eb_var ADMIN_USER_ID)" + } >> "$GITHUB_ENV" + + - name: Generate sandbox DB password + run: | + DB_PASSWORD=$(openssl rand -base64 24 | tr -d "=+/") + echo "::add-mask::$DB_PASSWORD" + echo "TF_VAR_db_master_password=$DB_PASSWORD" >> "$GITHUB_ENV" + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "~1.0" + terraform_wrapper: false + + - name: Terraform init + working-directory: infrastructure/environments/sandbox + run: terraform init + + - name: Terraform apply + working-directory: infrastructure/environments/sandbox + env: + TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} + run: terraform apply -auto-approve + + - name: Get sandbox EB URL + id: urls + working-directory: infrastructure/environments/sandbox + run: | + echo "eb_url=$(terraform output -raw eb_environment_url)" >> "$GITHUB_OUTPUT" + echo "eb_cname=$(terraform output -raw eb_cname)" >> "$GITHUB_OUTPUT" + + - name: Deploy app to sandbox EB + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + run: | + # Log into ECR (prod repo, us-east-1) to get the registry URL + ECR_REGISTRY=$(aws ecr get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin \ + "$(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com" \ + 2>/dev/null && \ + echo "$(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com") + + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com" + ECR_REPOSITORY="finishline-production" + + # Use the latest prod image — same image tested in sandbox = same image that goes to prod + cat > Dockerrun.aws.json </dev/null | tr '\t' '\n' | grep -v '^$' || true +} + +##################### +# Elastic Beanstalk +##################### +log "Terminating sandbox EB environments..." +for env in $(aws elasticbeanstalk describe-environments \ + --region "$REGION" \ + --query "Environments[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].EnvironmentName" \ + --output text 2>/dev/null || true); do + log " Terminating EB environment: $env" + aws elasticbeanstalk terminate-environment \ + --environment-name "$env" \ + --region "$REGION" || true +done + +# Wait for EB environments to finish terminating before touching VPC resources +for env in $(aws elasticbeanstalk describe-environments \ + --region "$REGION" \ + --query "Environments[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE'] && Status!='Terminated'].EnvironmentName" \ + --output text 2>/dev/null || true); do + log " Waiting for EB environment to terminate: $env" + aws elasticbeanstalk wait environment-terminated \ + --environment-name "$env" \ + --region "$REGION" || true +done + +##################### +# RDS Instances +##################### +log "Deleting sandbox RDS instances..." +for db in $(aws rds describe-db-instances \ + --region "$REGION" \ + --query "DBInstances[?TagList[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].DBInstanceIdentifier" \ + --output text 2>/dev/null || true); do + log " Deleting RDS instance: $db" + aws rds delete-db-instance \ + --db-instance-identifier "$db" \ + --skip-final-snapshot \ + --region "$REGION" || true + aws rds wait db-instance-deleted \ + --db-instance-identifier "$db" \ + --region "$REGION" || true +done + +log "Deleting sandbox DB subnet groups..." +for sg in $(aws rds describe-db-subnet-groups \ + --region "$REGION" \ + --query "DBSubnetGroups[?Tags[?Key=='$TAG_KEY' && Value=='$TAG_VALUE']].DBSubnetGroupName" \ + --output text 2>/dev/null || true); do + log " Deleting DB subnet group: $sg" + aws rds delete-db-subnet-group \ + --db-subnet-group-name "$sg" \ + --region "$REGION" || true +done + +##################### +# CloudWatch Log Groups +##################### +log "Deleting sandbox CloudWatch log groups..." +for lg in $(aws logs describe-log-groups \ + --region "$REGION" \ + --log-group-name-prefix "/aws/elasticbeanstalk/finishline-sandbox" \ + --query "logGroups[].logGroupName" \ + --output text 2>/dev/null || true); do + log " Deleting log group: $lg" + aws logs delete-log-group --log-group-name "$lg" --region "$REGION" || true +done + +##################### +# VPC Resources +# Must delete in dependency order: IGW → subnets → route table associations +# → non-main route tables → security group rules → security groups → VPC +##################### +for vpc in $(aws ec2 describe-vpcs \ + --region "$REGION" \ + --filters "Name=tag:$TAG_KEY,Values=$TAG_VALUE" \ + --query "Vpcs[].VpcId" \ + --output text 2>/dev/null || true); do + + log "Cleaning up VPC: $vpc" + + # Detach and delete internet gateways + for igw in $(aws ec2 describe-internet-gateways \ + --region "$REGION" \ + --filters "Name=attachment.vpc-id,Values=$vpc" \ + --query "InternetGateways[].InternetGatewayId" \ + --output text 2>/dev/null || true); do + log " Detaching IGW: $igw" + aws ec2 detach-internet-gateway --internet-gateway-id "$igw" --vpc-id "$vpc" --region "$REGION" || true + log " Deleting IGW: $igw" + aws ec2 delete-internet-gateway --internet-gateway-id "$igw" --region "$REGION" || true + done + + # Delete subnets + for subnet in $(aws ec2 describe-subnets \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "Subnets[].SubnetId" \ + --output text 2>/dev/null || true); do + log " Deleting subnet: $subnet" + aws ec2 delete-subnet --subnet-id "$subnet" --region "$REGION" || true + done + + # Delete non-main route tables + for rt in $(aws ec2 describe-route-tables \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "RouteTables[?Associations[?Main==\`false\`] || !Associations].RouteTableId" \ + --output text 2>/dev/null || true); do + log " Deleting route table: $rt" + aws ec2 delete-route-table --route-table-id "$rt" --region "$REGION" || true + done + + # Revoke all security group rules, then delete non-default security groups + for sg in $(aws ec2 describe-security-groups \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "SecurityGroups[?GroupName!='default'].GroupId" \ + --output text 2>/dev/null || true); do + + # Revoke ingress rules + INGRESS=$(aws ec2 describe-security-group-rules \ + --region "$REGION" \ + --filters "Name=group-id,Values=$sg" \ + --query "SecurityGroupRules[?!IsEgress].SecurityGroupRuleId" \ + --output text 2>/dev/null || true) + if [ -n "$INGRESS" ]; then + aws ec2 revoke-security-group-ingress \ + --group-id "$sg" \ + --security-group-rule-ids $INGRESS \ + --region "$REGION" || true + fi + + # Revoke egress rules + EGRESS=$(aws ec2 describe-security-group-rules \ + --region "$REGION" \ + --filters "Name=group-id,Values=$sg" \ + --query "SecurityGroupRules[?IsEgress].SecurityGroupRuleId" \ + --output text 2>/dev/null || true) + if [ -n "$EGRESS" ]; then + aws ec2 revoke-security-group-egress \ + --group-id "$sg" \ + --security-group-rule-ids $EGRESS \ + --region "$REGION" || true + fi + done + + # Now delete the security groups (after rules are gone) + for sg in $(aws ec2 describe-security-groups \ + --region "$REGION" \ + --filters "Name=vpc-id,Values=$vpc" \ + --query "SecurityGroups[?GroupName!='default'].GroupId" \ + --output text 2>/dev/null || true); do + log " Deleting security group: $sg" + aws ec2 delete-security-group --group-id "$sg" --region "$REGION" || true + done + + log " Deleting VPC: $vpc" + aws ec2 delete-vpc --vpc-id "$vpc" --region "$REGION" || true +done + +##################### +# IAM (sandbox roles and instance profiles) +##################### +log "Deleting sandbox IAM resources..." +for profile in $(aws iam list-instance-profiles \ + --query "InstanceProfiles[?starts_with(InstanceProfileName,'finishline-sandbox-')].InstanceProfileName" \ + --output text 2>/dev/null || true); do + for role in $(aws iam get-instance-profile \ + --instance-profile-name "$profile" \ + --query "InstanceProfile.Roles[].RoleName" \ + --output text 2>/dev/null || true); do + aws iam remove-role-from-instance-profile \ + --instance-profile-name "$profile" --role-name "$role" || true + done + log " Deleting instance profile: $profile" + aws iam delete-instance-profile --instance-profile-name "$profile" || true +done + +for role in $(aws iam list-roles \ + --query "Roles[?starts_with(RoleName,'finishline-sandbox-')].RoleName" \ + --output text 2>/dev/null || true); do + for policy in $(aws iam list-role-policies --role-name "$role" --query PolicyNames[] --output text 2>/dev/null || true); do + aws iam delete-role-policy --role-name "$role" --policy-name "$policy" || true + done + for arn in $(aws iam list-attached-role-policies --role-name "$role" --query "AttachedPolicies[].PolicyArn" --output text 2>/dev/null || true); do + aws iam detach-role-policy --role-name "$role" --policy-arn "$arn" || true + done + log " Deleting IAM role: $role" + aws iam delete-role --role-name "$role" || true +done + +log "Cleanup complete." From 57a7be0b7fdbbd5be0083eec6f0d6e4b96192a2e Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 12 Jun 2026 17:21:10 -0400 Subject: [PATCH 05/29] finishing touches --- .github/workflows/sandbox-up.yml | 23 +++++++----- infrastructure/bootstrap/main.tf | 37 +++++++++++++++++++ infrastructure/environments/sandbox/main.tf | 20 ++++++++++ .../environments/sandbox/outputs.tf | 9 +++++ .../environments/sandbox/variables.tf | 6 +++ 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 055d9299ec..9592024dbe 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -1,7 +1,10 @@ name: Sandbox Spin-Up on: - workflow_dispatch: + pull_request: + branches: + - multitenancy + types: [opened, synchronize, reopened] # Only one sandbox may exist at a time. concurrency: @@ -10,6 +13,7 @@ concurrency: permissions: contents: read + id-token: write jobs: spin-up: @@ -98,7 +102,6 @@ jobs: DRIVE_REFRESH_TOKEN=$(fetch drive-refresh-token) CALENDAR_REFRESH_TOKEN=$(fetch calendar-refresh-token) SLACK_BOT_TOKEN=$(fetch slack-bot-token) - SLACK_TOKEN_SECRET=$(fetch slack-token-secret) SLACK_SIGNING_SECRET=$(fetch slack-signing-secret) NOTIFICATION_ENDPOINT_SECRET=$(fetch notification-endpoint-secret) @@ -108,7 +111,6 @@ jobs: echo "::add-mask::$DRIVE_REFRESH_TOKEN" echo "::add-mask::$CALENDAR_REFRESH_TOKEN" echo "::add-mask::$SLACK_BOT_TOKEN" - echo "::add-mask::$SLACK_TOKEN_SECRET" echo "::add-mask::$SLACK_SIGNING_SECRET" echo "::add-mask::$NOTIFICATION_ENDPOINT_SECRET" @@ -119,15 +121,12 @@ jobs: echo "TF_VAR_drive_refresh_token=$DRIVE_REFRESH_TOKEN" echo "TF_VAR_calendar_refresh_token=$CALENDAR_REFRESH_TOKEN" echo "TF_VAR_slack_bot_token=$SLACK_BOT_TOKEN" - echo "TF_VAR_slack_token_secret=$SLACK_TOKEN_SECRET" echo "TF_VAR_slack_signing_secret=$SLACK_SIGNING_SECRET" echo "TF_VAR_notification_endpoint_secret=$NOTIFICATION_ENDPOINT_SECRET" } >> "$GITHUB_ENV" - name: Pull non-secret config from prod EB environment run: | - # These public values are already set on the prod EB environment — - # no developer setup needed, always in sync with prod. eb_var() { aws elasticbeanstalk describe-configuration-settings \ --application-name finishline-production \ @@ -137,12 +136,17 @@ jobs: --output text } + SLACK_TOKEN_SECRET=$(eb_var SLACK_TOKEN_SECRET) + echo "::add-mask::$SLACK_TOKEN_SECRET" + { + echo "TF_VAR_slack_token_secret=$SLACK_TOKEN_SECRET" echo "TF_VAR_google_client_id=$(eb_var GOOGLE_CLIENT_ID)" echo "TF_VAR_google_drive_folder_id=$(eb_var GOOGLE_DRIVE_FOLDER_ID)" echo "TF_VAR_slack_id=$(eb_var SLACK_ID)" echo "TF_VAR_user_email=$(eb_var USER_EMAIL)" echo "TF_VAR_admin_user_id=$(eb_var ADMIN_USER_ID)" + echo "TF_VAR_github_access_token=${{ secrets.GITHUB_TOKEN }}" } >> "$GITHUB_ENV" - name: Generate sandbox DB password @@ -167,12 +171,13 @@ jobs: TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} run: terraform apply -auto-approve - - name: Get sandbox EB URL + - name: Get sandbox URLs id: urls working-directory: infrastructure/environments/sandbox run: | echo "eb_url=$(terraform output -raw eb_environment_url)" >> "$GITHUB_OUTPUT" echo "eb_cname=$(terraform output -raw eb_cname)" >> "$GITHUB_OUTPUT" + echo "frontend_url=$(terraform output -raw frontend_url)" >> "$GITHUB_OUTPUT" - name: Deploy app to sandbox EB env: @@ -225,5 +230,5 @@ jobs: run: | echo "Sandbox is up!" echo "" - echo "Backend: ${{ steps.urls.outputs.eb_url }}" - echo "CNAME: ${{ steps.urls.outputs.eb_cname }}" + echo "Frontend: ${{ steps.urls.outputs.frontend_url }}" + echo "Backend: ${{ steps.urls.outputs.eb_url }}" diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 4ef905d8d7..a3201dbb2c 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -499,6 +499,43 @@ resource "aws_iam_role_policy" "cicd_cloudwatch" { }) } +# Amplify — create/delete the sandbox Amplify app and branch +resource "aws_iam_role_policy" "cicd_amplify" { + name = "sandbox-amplify" + role = aws_iam_role.cicd.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AmplifyManage" + Effect = "Allow" + Action = [ + "amplify:CreateApp", + "amplify:DeleteApp", + "amplify:GetApp", + "amplify:ListApps", + "amplify:UpdateApp", + "amplify:CreateBranch", + "amplify:DeleteBranch", + "amplify:GetBranch", + "amplify:ListBranches", + "amplify:UpdateBranch", + "amplify:StartJob", + "amplify:StopJob", + "amplify:GetJob", + "amplify:ListJobs", + "amplify:TagResource", + "amplify:ListTagsForResource", + "amplify:CreateWebhook", + "amplify:DeleteWebhook" + ] + Resource = "*" + } + ] + }) +} + # Secrets Manager — read prod secrets, write sandbox DATABASE_URL resource "aws_iam_role_policy" "cicd_secrets" { name = "sandbox-secrets" diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index 13f7647d9b..372f8413ef 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -129,6 +129,7 @@ module "elasticbeanstalk" { SLACK_SIGNING_SECRET = var.slack_signing_secret NOTIFICATION_ENDPOINT_SECRET = var.notification_endpoint_secret + NODE_ENV = "sandbox" LOG_LEVEL = "info" GOOGLE_CLIENT_ID = var.google_client_id REACT_APP_GOOGLE_AUTH_CLIENT_ID = var.google_client_id @@ -139,6 +140,25 @@ module "elasticbeanstalk" { } } +############# +# Amplify Frontend Module +############# +module "frontend" { + source = "../../modules/amplify-frontend" + + project_name = "finishline" + environment = "sandbox" + github_repository = "https://github.com/Northeastern-Electric-Racing/FinishLine" + github_access_token = var.github_access_token + main_branch_name = "develop" + backend_api_url = module.elasticbeanstalk.environment_endpoint_url + + domain_name = "" + enable_pull_request_preview = false + enable_auto_branch_creation = false + create_webhook = false +} + ############# # CloudWatch Log Group (skip full monitoring module — no dashboards or alarms needed for sandbox) ############# diff --git a/infrastructure/environments/sandbox/outputs.tf b/infrastructure/environments/sandbox/outputs.tf index e30ba9ffa0..23e7b16165 100644 --- a/infrastructure/environments/sandbox/outputs.tf +++ b/infrastructure/environments/sandbox/outputs.tf @@ -43,3 +43,12 @@ output "eb_cname" { description = "Raw CNAME of the sandbox EB environment" value = module.elasticbeanstalk.environment_cname } + +##################### +# Frontend Outputs +##################### + +output "frontend_url" { + description = "URL of the sandbox Amplify frontend" + value = module.frontend.frontend_url +} diff --git a/infrastructure/environments/sandbox/variables.tf b/infrastructure/environments/sandbox/variables.tf index e94825e996..f445225e6c 100644 --- a/infrastructure/environments/sandbox/variables.tf +++ b/infrastructure/environments/sandbox/variables.tf @@ -14,6 +14,12 @@ variable "db_master_password" { # In CI/CD: generated once and stored in GitHub Actions secrets } +variable "github_access_token" { + description = "GitHub personal access token for Amplify to access the repository" + type = string + sensitive = true +} + variable "snapshot_identifier" { description = "RDS snapshot ID to restore from (taken from prod by the CI/CD pipeline before spin-up)" type = string From bdca4d15cc9349023c98b223935e4b758b0c5ba1 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 12 Jun 2026 17:22:29 -0400 Subject: [PATCH 06/29] enable testing before merging --- .github/workflows/sandbox-up.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 9592024dbe..f988c08662 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -5,6 +5,7 @@ on: branches: - multitenancy types: [opened, synchronize, reopened] + workflow_dispatch: # Only one sandbox may exist at a time. concurrency: From b29e24cc795851129cc92cce629eeef029b0b23b Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 15 Jun 2026 16:08:59 -0400 Subject: [PATCH 07/29] bugs found testing --- infrastructure/modules/elasticbeanstalk/main.tf | 7 ++++++- infrastructure/modules/elasticbeanstalk/variables.tf | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/elasticbeanstalk/main.tf b/infrastructure/modules/elasticbeanstalk/main.tf index ea9c9b4bd2..89d8469305 100644 --- a/infrastructure/modules/elasticbeanstalk/main.tf +++ b/infrastructure/modules/elasticbeanstalk/main.tf @@ -1,5 +1,10 @@ # Elastic Beanstalk Module +data "aws_elastic_beanstalk_solution_stack" "docker" { + most_recent = true + name_regex = "^64bit Amazon Linux 2023 .* running Docker$" +} + ############# # Elastic Beanstalk Application ############# @@ -26,7 +31,7 @@ resource "aws_elastic_beanstalk_application" "main" { resource "aws_elastic_beanstalk_environment" "main" { name = "${var.project_name}-${var.environment}-env" application = aws_elastic_beanstalk_application.main.name - solution_stack_name = var.solution_stack_name + solution_stack_name = var.solution_stack_name != "" ? var.solution_stack_name : data.aws_elastic_beanstalk_solution_stack.docker.name tier = "WebServer" ##################### diff --git a/infrastructure/modules/elasticbeanstalk/variables.tf b/infrastructure/modules/elasticbeanstalk/variables.tf index 21979a1e28..041da68107 100644 --- a/infrastructure/modules/elasticbeanstalk/variables.tf +++ b/infrastructure/modules/elasticbeanstalk/variables.tf @@ -14,7 +14,7 @@ variable "solution_stack_name" { description = "Elastic Beanstalk solution stack name" type = string # Find the latest: aws elasticbeanstalk list-available-solution-stacks - default = "64bit Amazon Linux 2023 v4.11.0 running Docker" + default = "" } variable "vpc_id" { From db8a4b1715e88ab7067de14c834d3687f7ddff5c Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 15 Jun 2026 16:59:46 -0400 Subject: [PATCH 08/29] remove keyless --- infrastructure/bootstrap/main.tf | 381 -------------------------- infrastructure/bootstrap/outputs.tf | 11 +- infrastructure/bootstrap/variables.tf | 5 - 3 files changed, 3 insertions(+), 394 deletions(-) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index a3201dbb2c..3e6c5adadd 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -175,384 +175,3 @@ resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { } } -############# -# GitHub Actions OIDC Provider -# Allows GitHub Actions workflows to assume AWS roles without long-lived credentials. -# This is a global IAM resource — only one per account regardless of region. -############# -resource "aws_iam_openid_connect_provider" "github_actions" { - url = "https://token.actions.githubusercontent.com" - - client_id_list = ["sts.amazonaws.com"] - - # Stable thumbprint for token.actions.githubusercontent.com - thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"] - - tags = { - Name = "github-actions-oidc" - Purpose = "Allows GitHub Actions to assume AWS roles via OIDC" - } -} - -############# -# CI/CD Role — assumed by GitHub Actions to spin up/tear down the sandbox -############# -resource "aws_iam_role" "cicd" { - name = "finishline-cicd" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Principal = { - Federated = aws_iam_openid_connect_provider.github_actions.arn - } - Action = "sts:AssumeRoleWithWebIdentity" - Condition = { - StringLike = { - "token.actions.githubusercontent.com:sub" = "repo:${var.github_repo}:*" - } - StringEquals = { - "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" - } - } - } - ] - }) - - tags = { - Name = "finishline-cicd" - Purpose = "GitHub Actions sandbox spin-up and tear-down" - } -} - -# Terraform state access (scoped to sandbox key only) -resource "aws_iam_role_policy" "cicd_terraform_state" { - name = "terraform-state-access" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "StateReadWrite" - Effect = "Allow" - Action = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject", - "s3:ListBucket" - ] - Resource = [ - "arn:aws:s3:::${var.state_bucket_name}", - "arn:aws:s3:::${var.state_bucket_name}/sandbox/*" - ] - }, - { - Sid = "StateLocking" - Effect = "Allow" - Action = [ - "dynamodb:GetItem", - "dynamodb:PutItem", - "dynamodb:DeleteItem" - ] - Resource = "arn:aws:dynamodb:${var.aws_region}:*:table/${var.locks_table_name}" - } - ] - }) -} - -# Network — VPC, subnets, IGW, route tables, security groups (all tagged sandbox) -resource "aws_iam_role_policy" "cicd_network" { - name = "sandbox-network" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "EC2NetworkFull" - Effect = "Allow" - Action = [ - "ec2:CreateVpc", - "ec2:DeleteVpc", - "ec2:ModifyVpcAttribute", - "ec2:DescribeVpcs", - "ec2:CreateSubnet", - "ec2:DeleteSubnet", - "ec2:ModifySubnetAttribute", - "ec2:DescribeSubnets", - "ec2:CreateInternetGateway", - "ec2:DeleteInternetGateway", - "ec2:AttachInternetGateway", - "ec2:DetachInternetGateway", - "ec2:DescribeInternetGateways", - "ec2:CreateRouteTable", - "ec2:DeleteRouteTable", - "ec2:CreateRoute", - "ec2:DeleteRoute", - "ec2:AssociateRouteTable", - "ec2:DisassociateRouteTable", - "ec2:DescribeRouteTables", - "ec2:CreateSecurityGroup", - "ec2:DeleteSecurityGroup", - "ec2:AuthorizeSecurityGroupIngress", - "ec2:AuthorizeSecurityGroupEgress", - "ec2:RevokeSecurityGroupIngress", - "ec2:RevokeSecurityGroupEgress", - "ec2:DescribeSecurityGroups", - "ec2:DescribeSecurityGroupRules", - "ec2:CreateTags", - "ec2:DescribeAvailabilityZones", - "ec2:DescribeAccountAttributes" - ] - Resource = "*" - } - ] - }) -} - -# RDS — create/delete instances and snapshots (scoped to sandbox identifier prefix) -resource "aws_iam_role_policy" "cicd_rds" { - name = "sandbox-rds" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "SandboxRDSManage" - Effect = "Allow" - Action = [ - "rds:CreateDBInstance", - "rds:DeleteDBInstance", - "rds:ModifyDBInstance", - "rds:DescribeDBInstances", - "rds:CreateDBSubnetGroup", - "rds:DeleteDBSubnetGroup", - "rds:DescribeDBSubnetGroups", - "rds:AddTagsToResource", - "rds:ListTagsForResource", - "rds:DescribeDBParameterGroups" - ] - Resource = "*" - Condition = { - StringLike = { - "rds:db-tag/Environment" = "sandbox" - } - } - }, - { - # DescribeDBInstances and subnet group ops don't support tag conditions - Sid = "RDSDescribeGlobal" - Effect = "Allow" - Action = [ - "rds:DescribeDBInstances", - "rds:DescribeDBSubnetGroups", - "rds:DescribeDBSnapshots", - "rds:DescribeDBEngineVersions" - ] - Resource = "*" - }, - { - Sid = "ProdSnapshotRead" - Effect = "Allow" - Action = [ - "rds:CreateDBSnapshot", - "rds:DescribeDBSnapshots", - "rds:RestoreDBInstanceFromDBSnapshot", - "rds:CopyDBSnapshot" - ] - Resource = "*" - } - ] - }) -} - -# Elastic Beanstalk — full access scoped to finishline-sandbox resources -resource "aws_iam_role_policy" "cicd_elasticbeanstalk" { - name = "sandbox-elasticbeanstalk" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "EBManage" - Effect = "Allow" - Action = [ - "elasticbeanstalk:CreateApplication", - "elasticbeanstalk:DeleteApplication", - "elasticbeanstalk:DescribeApplications", - "elasticbeanstalk:CreateEnvironment", - "elasticbeanstalk:DeleteEnvironment", - "elasticbeanstalk:DescribeEnvironments", - "elasticbeanstalk:DescribeEnvironmentResources", - "elasticbeanstalk:UpdateEnvironment", - "elasticbeanstalk:TerminateEnvironment", - "elasticbeanstalk:CreateApplicationVersion", - "elasticbeanstalk:DeleteApplicationVersion", - "elasticbeanstalk:DescribeApplicationVersions", - "elasticbeanstalk:DescribeConfigurationSettings", - "elasticbeanstalk:DescribeConfigurationOptions", - "elasticbeanstalk:ValidateConfigurationSettings", - "elasticbeanstalk:ListTagsForResource", - "elasticbeanstalk:AddTags", - "elasticbeanstalk:DescribeEvents" - ] - Resource = "*" - }, - { - # EB needs to manage ELB/ASG/EC2 resources on your behalf - Sid = "EBSupportingServices" - Effect = "Allow" - Action = [ - "autoscaling:*", - "elasticloadbalancing:*", - "cloudwatch:PutMetricAlarm", - "cloudwatch:DeleteAlarms", - "cloudwatch:DescribeAlarms", - "s3:GetObject", - "s3:PutObject", - "s3:ListBucket", - "s3:DeleteObject" - ] - Resource = "*" - } - ] - }) -} - -# IAM — create/delete the sandbox EB roles (name-scoped to finishline-sandbox-*) -resource "aws_iam_role_policy" "cicd_iam" { - name = "sandbox-iam" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "SandboxRolesManage" - Effect = "Allow" - Action = [ - "iam:CreateRole", - "iam:DeleteRole", - "iam:GetRole", - "iam:PutRolePolicy", - "iam:DeleteRolePolicy", - "iam:GetRolePolicy", - "iam:AttachRolePolicy", - "iam:DetachRolePolicy", - "iam:ListAttachedRolePolicies", - "iam:ListRolePolicies", - "iam:CreateInstanceProfile", - "iam:DeleteInstanceProfile", - "iam:GetInstanceProfile", - "iam:AddRoleToInstanceProfile", - "iam:RemoveRoleFromInstanceProfile", - "iam:TagRole", - "iam:UntagRole", - "iam:ListInstanceProfilesForRole" - ] - Resource = [ - "arn:aws:iam::*:role/finishline-sandbox-*", - "arn:aws:iam::*:instance-profile/finishline-sandbox-*" - ] - }, - { - Sid = "PassRoleToEB" - Effect = "Allow" - Action = "iam:PassRole" - Resource = "arn:aws:iam::*:role/finishline-sandbox-*" - } - ] - }) -} - -# CloudWatch — log groups for sandbox -resource "aws_iam_role_policy" "cicd_cloudwatch" { - name = "sandbox-cloudwatch" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "LogGroupManage" - Effect = "Allow" - Action = [ - "logs:CreateLogGroup", - "logs:DeleteLogGroup", - "logs:DescribeLogGroups", - "logs:PutRetentionPolicy", - "logs:ListTagsLogGroup", - "logs:TagLogGroup", - "logs:UntagLogGroup", - "logs:ListTagsForResource", - "logs:TagResource", - "logs:UntagResource" - ] - Resource = "arn:aws:logs:*:*:log-group:/aws/elasticbeanstalk/finishline-sandbox*" - } - ] - }) -} - -# Amplify — create/delete the sandbox Amplify app and branch -resource "aws_iam_role_policy" "cicd_amplify" { - name = "sandbox-amplify" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "AmplifyManage" - Effect = "Allow" - Action = [ - "amplify:CreateApp", - "amplify:DeleteApp", - "amplify:GetApp", - "amplify:ListApps", - "amplify:UpdateApp", - "amplify:CreateBranch", - "amplify:DeleteBranch", - "amplify:GetBranch", - "amplify:ListBranches", - "amplify:UpdateBranch", - "amplify:StartJob", - "amplify:StopJob", - "amplify:GetJob", - "amplify:ListJobs", - "amplify:TagResource", - "amplify:ListTagsForResource", - "amplify:CreateWebhook", - "amplify:DeleteWebhook" - ] - Resource = "*" - } - ] - }) -} - -# Secrets Manager — read prod secrets, write sandbox DATABASE_URL -resource "aws_iam_role_policy" "cicd_secrets" { - name = "sandbox-secrets" - role = aws_iam_role.cicd.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "ReadProdSecrets" - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ] - Resource = "arn:aws:secretsmanager:*:*:secret:finishline/production/*" - } - ] - }) -} diff --git a/infrastructure/bootstrap/outputs.tf b/infrastructure/bootstrap/outputs.tf index 1c40aaa356..170bd66ea9 100644 --- a/infrastructure/bootstrap/outputs.tf +++ b/infrastructure/bootstrap/outputs.tf @@ -30,28 +30,23 @@ output "eb_versions_bucket_arn" { value = aws_s3_bucket.eb_versions.arn } -output "cicd_role_arn" { - description = "ARN of the CI/CD role — add this as the AWS_CICD_ROLE_ARN GitHub Actions secret" - value = aws_iam_role.cicd.arn -} - output "next_steps" { description = "Instructions for next steps" value = <<-EOT Bootstrap Complete! - + The following resources have been created: - S3 Bucket for Terraform State: ${aws_s3_bucket.terraform_state.id} - DynamoDB Table for State Locking: ${aws_dynamodb_table.terraform_locks.id} - S3 Bucket for EB Versions: ${aws_s3_bucket.eb_versions.id} - + Next Steps: 1. The backend configuration in ../backend.tf is already configured to use these resources 2. Navigate to your environment directory: cd ../environments/production 3. Initialize Terraform: terraform init 4. Review the plan: terraform plan 5. Apply the infrastructure: terraform apply - + Note: Keep this bootstrap state file safe! It's stored locally in this directory. EOT } diff --git a/infrastructure/bootstrap/variables.tf b/infrastructure/bootstrap/variables.tf index 7e8265f432..d8465ccb18 100644 --- a/infrastructure/bootstrap/variables.tf +++ b/infrastructure/bootstrap/variables.tf @@ -24,8 +24,3 @@ variable "eb_versions_bucket_name" { default = "finishline-eb-versions" } -variable "github_repo" { - description = "GitHub repo in org/name format — used to scope the OIDC trust policy" - type = string - default = "Northeastern-Electric-Racing/FinishLine" -} From 3386ca89faf9150bb1ccce5b6af9eca192277641 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 25 Jun 2026 20:40:28 -0400 Subject: [PATCH 09/29] change spin up and down to sandbox for testing --- .github/workflows/sandbox-down.yml | 2 +- .github/workflows/sandbox-up.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sandbox-down.yml b/.github/workflows/sandbox-down.yml index 426d133a12..45fe7254d7 100644 --- a/.github/workflows/sandbox-down.yml +++ b/.github/workflows/sandbox-down.yml @@ -4,7 +4,7 @@ on: pull_request: types: [closed] branches: - - multitenancy + - sandbox # Share the concurrency group with sandbox-up so they can't run simultaneously. concurrency: diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index f988c08662..b1d2a8ec38 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -3,7 +3,7 @@ name: Sandbox Spin-Up on: pull_request: branches: - - multitenancy + - sandbox types: [opened, synchronize, reopened] workflow_dispatch: From ffc395cc4a2ec67feec0b0940cd2e5b7b3d832ae Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 25 Jun 2026 21:53:24 -0400 Subject: [PATCH 10/29] fix cross-region snapshot copy: add kms key and permissions --- .github/workflows/sandbox-up.yml | 1 + infrastructure/bootstrap/main.tf | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index b1d2a8ec38..339f8764ab 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -78,6 +78,7 @@ jobs: aws rds copy-db-snapshot \ --source-db-snapshot-identifier "$SOURCE_ARN" \ --target-db-snapshot-identifier "$COPY_ID" \ + --kms-key-id alias/aws/rds \ --region us-east-2 echo "Waiting for snapshot copy to become available (~5 min)..." diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 3e6c5adadd..6028772f93 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -1,4 +1,7 @@ # Bootstrap Infrastructure +# +# NOTE: The github-actions-finishline IAM user was created manually outside of Terraform. +# The policy below attaches to that existing user by name. # This Terraform configuration creates the foundational resources needed # for managing Terraform state remotely. # @@ -155,6 +158,76 @@ resource "aws_s3_bucket_public_access_block" "eb_versions" { restrict_public_buckets = true } +############# +# IAM Policy for Sandbox Workflow Operations +# The github-actions-finishline user needs these permissions for sandbox-up.yml: +# - RDS snapshot operations (create from prod, copy cross-region) +# - Secrets Manager reads (pull prod secrets) +# - EB describe (pull prod non-secret config) +############# +resource "aws_iam_user_policy" "github_actions_sandbox" { + name = "sandbox-workflow-permissions" + user = "github-actions-finishline" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "RDSSnapshotOperations" + Effect = "Allow" + Action = [ + "rds:CreateDBSnapshot", + "rds:DescribeDBSnapshots", + "rds:CopyDBSnapshot", + "rds:DeleteDBSnapshot", + "rds:ListTagsForResource", + "rds:AddTagsToResource" + ] + Resource = "*" + }, + { + Sid = "SecretsManagerReadProd" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" + }, + { + Sid = "KMSForSnapshotCopy" + Effect = "Allow" + Action = [ + "kms:CreateGrant", + "kms:DescribeKey", + "kms:GenerateDataKey", + "kms:Decrypt", + "kms:ReEncryptFrom", + "kms:ReEncryptTo" + ] + Resource = "*" + Condition = { + StringLike = { + "kms:ViaService" = [ + "rds.us-east-1.amazonaws.com", + "rds.us-east-2.amazonaws.com" + ] + } + } + }, + { + Sid = "ElasticBeanstalkDescribeProd" + Effect = "Allow" + Action = [ + "elasticbeanstalk:DescribeConfigurationSettings", + "elasticbeanstalk:DescribeEnvironments" + ] + Resource = "*" + } + ] + }) +} + # Clean up old EB versions after 90 days resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { bucket = aws_s3_bucket.eb_versions.id From b8b7e7c88dd707f8c2e2a856b8e8b9f973ab55b3 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 25 Jun 2026 22:15:36 -0400 Subject: [PATCH 11/29] add full sandbox IAM permissions to github-actions user --- infrastructure/bootstrap/main.tf | 47 ++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 6028772f93..7011d009e6 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -160,10 +160,15 @@ resource "aws_s3_bucket_public_access_block" "eb_versions" { ############# # IAM Policy for Sandbox Workflow Operations +<<<<<<< Updated upstream # The github-actions-finishline user needs these permissions for sandbox-up.yml: # - RDS snapshot operations (create from prod, copy cross-region) # - Secrets Manager reads (pull prod secrets) # - EB describe (pull prod non-secret config) +======= +# Attaches to the manually-created github-actions-finishline user. +# Grants permissions needed by sandbox-up.yml that the user doesn't already have. +>>>>>>> Stashed changes ############# resource "aws_iam_user_policy" "github_actions_sandbox" { name = "sandbox-workflow-permissions" @@ -173,6 +178,33 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Version = "2012-10-17" Statement = [ { +<<<<<<< Updated upstream +======= + Sid = "TerraformStateS3" + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ] + Resource = [ + "arn:aws:s3:::finishline-terraform-state", + "arn:aws:s3:::finishline-terraform-state/*" + ] + }, + { + Sid = "TerraformStateDynamoDB" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem" + ] + Resource = "arn:aws:dynamodb:us-east-1:830877454256:table/finishline-terraform-locks" + }, + { +>>>>>>> Stashed changes Sid = "RDSSnapshotOperations" Effect = "Allow" Action = [ @@ -186,6 +218,7 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "*" }, { +<<<<<<< Updated upstream Sid = "SecretsManagerReadProd" Effect = "Allow" Action = [ @@ -195,6 +228,8 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" }, { +======= +>>>>>>> Stashed changes Sid = "KMSForSnapshotCopy" Effect = "Allow" Action = [ @@ -216,6 +251,18 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { } }, { +<<<<<<< Updated upstream +======= + Sid = "SecretsManagerReadProd" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" + }, + { +>>>>>>> Stashed changes Sid = "ElasticBeanstalkDescribeProd" Effect = "Allow" Action = [ From ba36f420307f2ae69af7190bb77d6d423c657737 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 25 Jun 2026 22:31:30 -0400 Subject: [PATCH 12/29] fix amplify: make access_token optional, trigger build via CLI --- .github/workflows/sandbox-up.yml | 29 ++++++++++++++++++- .../environments/sandbox/outputs.tf | 5 ++++ .../environments/sandbox/variables.tf | 1 + .../modules/amplify-frontend/main.tf | 4 +-- .../modules/amplify-frontend/variables.tf | 1 + 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 339f8764ab..11084f064b 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -148,7 +148,6 @@ jobs: echo "TF_VAR_slack_id=$(eb_var SLACK_ID)" echo "TF_VAR_user_email=$(eb_var USER_EMAIL)" echo "TF_VAR_admin_user_id=$(eb_var ADMIN_USER_ID)" - echo "TF_VAR_github_access_token=${{ secrets.GITHUB_TOKEN }}" } >> "$GITHUB_ENV" - name: Generate sandbox DB password @@ -173,6 +172,34 @@ jobs: TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} run: terraform apply -auto-approve + - name: Trigger Amplify build + working-directory: infrastructure/environments/sandbox + run: | + APP_ID=$(terraform output -raw amplify_app_id) + JOB_ID=$(aws amplify start-job \ + --app-id "$APP_ID" \ + --branch-name develop \ + --job-type RELEASE \ + --region us-east-1 \ + --query "jobSummary.jobId" \ + --output text) + echo "Waiting for Amplify build (job $JOB_ID)..." + while true; do + STATUS=$(aws amplify get-job \ + --app-id "$APP_ID" \ + --branch-name develop \ + --job-id "$JOB_ID" \ + --region us-east-1 \ + --query "job.summary.status" \ + --output text) + echo " status: $STATUS" + case "$STATUS" in + SUCCEED) echo "Amplify build succeeded."; break ;; + FAILED|CANCELLED) echo "Amplify build $STATUS."; exit 1 ;; + *) sleep 30 ;; + esac + done + - name: Get sandbox URLs id: urls working-directory: infrastructure/environments/sandbox diff --git a/infrastructure/environments/sandbox/outputs.tf b/infrastructure/environments/sandbox/outputs.tf index 23e7b16165..552ced20d7 100644 --- a/infrastructure/environments/sandbox/outputs.tf +++ b/infrastructure/environments/sandbox/outputs.tf @@ -52,3 +52,8 @@ output "frontend_url" { description = "URL of the sandbox Amplify frontend" value = module.frontend.frontend_url } + +output "amplify_app_id" { + description = "Amplify app ID (used to trigger manual builds)" + value = module.frontend.amplify_app_id +} diff --git a/infrastructure/environments/sandbox/variables.tf b/infrastructure/environments/sandbox/variables.tf index f445225e6c..21645ccd97 100644 --- a/infrastructure/environments/sandbox/variables.tf +++ b/infrastructure/environments/sandbox/variables.tf @@ -18,6 +18,7 @@ variable "github_access_token" { description = "GitHub personal access token for Amplify to access the repository" type = string sensitive = true + default = "" } variable "snapshot_identifier" { diff --git a/infrastructure/modules/amplify-frontend/main.tf b/infrastructure/modules/amplify-frontend/main.tf index 446aa8608d..1b491cb1b9 100644 --- a/infrastructure/modules/amplify-frontend/main.tf +++ b/infrastructure/modules/amplify-frontend/main.tf @@ -7,8 +7,8 @@ resource "aws_amplify_app" "frontend" { name = "${var.project_name}-${var.environment}-frontend" repository = var.github_repository - # GitHub access token for repository access - access_token = var.github_access_token + # GitHub access token for webhook setup — optional; omit for public repos + access_token = var.github_access_token != "" ? var.github_access_token : null # Build specification build_spec = <<-EOT diff --git a/infrastructure/modules/amplify-frontend/variables.tf b/infrastructure/modules/amplify-frontend/variables.tf index f4abff0d35..68ab5f80fe 100644 --- a/infrastructure/modules/amplify-frontend/variables.tf +++ b/infrastructure/modules/amplify-frontend/variables.tf @@ -19,6 +19,7 @@ variable "github_access_token" { description = "GitHub personal access token for repository access" type = string sensitive = true + default = "" } variable "main_branch_name" { From cff10d2757e2ab96c0bdbb9d23651551a58a3344 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 25 Jun 2026 22:43:14 -0400 Subject: [PATCH 13/29] add managed IAM policies for sandbox Terraform provisioning --- infrastructure/bootstrap/main.tf | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 7011d009e6..598d55d621 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -159,6 +159,7 @@ resource "aws_s3_bucket_public_access_block" "eb_versions" { } ############# +<<<<<<< Updated upstream # IAM Policy for Sandbox Workflow Operations <<<<<<< Updated upstream # The github-actions-finishline user needs these permissions for sandbox-up.yml: @@ -173,13 +174,46 @@ resource "aws_s3_bucket_public_access_block" "eb_versions" { resource "aws_iam_user_policy" "github_actions_sandbox" { name = "sandbox-workflow-permissions" user = "github-actions-finishline" +======= +# IAM Permissions for github-actions-finishline +# Attaches managed policies so Terraform can provision sandbox resources. +# Also adds an inline policy for operations specific to the sandbox workflow. +############# + +locals { + github_actions_user = "github-actions-finishline" + managed_policies = { + ec2 = "arn:aws:iam::aws:policy/AmazonEC2FullAccess" + rds = "arn:aws:iam::aws:policy/AmazonRDSFullAccess" + iam = "arn:aws:iam::aws:policy/IAMFullAccess" + eb = "arn:aws:iam::aws:policy/AdministratorAccess-AWSElasticBeanstalk" + s3 = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + cloudwatch = "arn:aws:iam::aws:policy/CloudWatchFullAccess" + amplify = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify" + logs = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" + } +} + +resource "aws_iam_user_policy_attachment" "github_actions_managed" { + for_each = local.managed_policies + user = local.github_actions_user + policy_arn = each.value +} + +resource "aws_iam_user_policy" "github_actions_sandbox" { + name = "sandbox-workflow-permissions" + user = local.github_actions_user +>>>>>>> Stashed changes policy = jsonencode({ Version = "2012-10-17" Statement = [ { <<<<<<< Updated upstream +<<<<<<< Updated upstream +======= ======= +>>>>>>> Stashed changes Sid = "TerraformStateS3" Effect = "Allow" Action = [ @@ -204,6 +238,9 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "arn:aws:dynamodb:us-east-1:830877454256:table/finishline-terraform-locks" }, { +<<<<<<< Updated upstream +>>>>>>> Stashed changes +======= >>>>>>> Stashed changes Sid = "RDSSnapshotOperations" Effect = "Allow" @@ -218,6 +255,7 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "*" }, { +<<<<<<< Updated upstream <<<<<<< Updated upstream Sid = "SecretsManagerReadProd" Effect = "Allow" @@ -229,6 +267,8 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { }, { ======= +>>>>>>> Stashed changes +======= >>>>>>> Stashed changes Sid = "KMSForSnapshotCopy" Effect = "Allow" @@ -252,7 +292,10 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { }, { <<<<<<< Updated upstream +<<<<<<< Updated upstream +======= ======= +>>>>>>> Stashed changes Sid = "SecretsManagerReadProd" Effect = "Allow" Action = [ @@ -262,6 +305,9 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" }, { +<<<<<<< Updated upstream +>>>>>>> Stashed changes +======= >>>>>>> Stashed changes Sid = "ElasticBeanstalkDescribeProd" Effect = "Allow" From 525a508047fcf613a93b577f3143c58a20db3714 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 14:57:40 -0400 Subject: [PATCH 14/29] manual Amplify zip deploy, full IAM permissions for CI user --- .github/workflows/sandbox-up.yml | 43 ++++-- infrastructure/bootstrap/main.tf | 129 +++++------------- infrastructure/environments/sandbox/main.tf | 8 +- .../environments/sandbox/variables.tf | 7 - .../modules/amplify-frontend/main.tf | 6 +- .../modules/amplify-frontend/variables.tf | 11 -- 6 files changed, 73 insertions(+), 131 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 11084f064b..c9b670278c 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -172,18 +172,43 @@ jobs: TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} run: terraform apply -auto-approve - - name: Trigger Amplify build + - name: Build and deploy frontend to Amplify working-directory: infrastructure/environments/sandbox run: | APP_ID=$(terraform output -raw amplify_app_id) - JOB_ID=$(aws amplify start-job \ + + # Install deps and build + cd "$GITHUB_WORKSPACE" + yarn install --frozen-lockfile + yarn workspace shared build + yarn workspace frontend build + + # Zip the built artifacts (files at root, not nested) + cd src/frontend/dist + zip -r /tmp/frontend.zip . + cd "$GITHUB_WORKSPACE" + + # Create a manual deployment slot and get the upload URL + DEPLOY=$(aws amplify create-deployment \ --app-id "$APP_ID" \ --branch-name develop \ - --job-type RELEASE \ - --region us-east-1 \ - --query "jobSummary.jobId" \ - --output text) - echo "Waiting for Amplify build (job $JOB_ID)..." + --region us-east-1) + JOB_ID=$(echo "$DEPLOY" | jq -r '.jobId') + UPLOAD_URL=$(echo "$DEPLOY" | jq -r '.zipUploadUrl') + + # Upload the artifact + curl -X PUT "$UPLOAD_URL" \ + -H "Content-Type: application/zip" \ + --data-binary @/tmp/frontend.zip + + # Start the deployment + aws amplify start-deployment \ + --app-id "$APP_ID" \ + --branch-name develop \ + --job-id "$JOB_ID" \ + --region us-east-1 + + echo "Waiting for Amplify deployment..." while true; do STATUS=$(aws amplify get-job \ --app-id "$APP_ID" \ @@ -194,8 +219,8 @@ jobs: --output text) echo " status: $STATUS" case "$STATUS" in - SUCCEED) echo "Amplify build succeeded."; break ;; - FAILED|CANCELLED) echo "Amplify build $STATUS."; exit 1 ;; + SUCCEED) echo "Frontend deployed."; break ;; + FAILED|CANCELLED) echo "Amplify deployment $STATUS."; exit 1 ;; *) sleep 30 ;; esac done diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 598d55d621..a0f7c25f42 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -1,7 +1,4 @@ # Bootstrap Infrastructure -# -# NOTE: The github-actions-finishline IAM user was created manually outside of Terraform. -# The policy below attaches to that existing user by name. # This Terraform configuration creates the foundational resources needed # for managing Terraform state remotely. # @@ -12,6 +9,7 @@ # 1. S3 bucket for Terraform state storage # 2. DynamoDB table for state locking # 3. S3 bucket for Elastic Beanstalk application versions +# 4. IAM permissions for the github-actions-finishline CI/CD user terraform { required_version = ">= 1.0" @@ -35,9 +33,9 @@ provider "aws" { default_tags { tags = { - Project = "finishline" - ManagedBy = "Terraform" - Purpose = "Bootstrap" + Project = "finishline" + ManagedBy = "Terraform" + Purpose = "Bootstrap" } } } @@ -58,7 +56,6 @@ resource "aws_s3_bucket" "terraform_state" { } } -# Versioning for state file history and recovery resource "aws_s3_bucket_versioning" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id @@ -67,7 +64,6 @@ resource "aws_s3_bucket_versioning" "terraform_state" { } } -# Server-side encryption for state files resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id @@ -78,7 +74,6 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" } } -# Block all public access to state bucket resource "aws_s3_bucket_public_access_block" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id @@ -88,7 +83,6 @@ resource "aws_s3_bucket_public_access_block" "terraform_state" { restrict_public_buckets = true } -# Delete old versions of state files after 90 days resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id @@ -109,7 +103,7 @@ resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" { ############# resource "aws_dynamodb_table" "terraform_locks" { name = var.locks_table_name - billing_mode = "PAY_PER_REQUEST" # On-demand pricing, no minimum cost + billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" attribute { @@ -139,7 +133,6 @@ resource "aws_s3_bucket" "eb_versions" { } } -# Enable versioning for EB application versions resource "aws_s3_bucket_versioning" "eb_versions" { bucket = aws_s3_bucket.eb_versions.id @@ -148,7 +141,6 @@ resource "aws_s3_bucket_versioning" "eb_versions" { } } -# Block public access to EB versions bucket resource "aws_s3_bucket_public_access_block" "eb_versions" { bucket = aws_s3_bucket.eb_versions.id @@ -158,39 +150,43 @@ resource "aws_s3_bucket_public_access_block" "eb_versions" { restrict_public_buckets = true } +resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { + bucket = aws_s3_bucket.eb_versions.id + + rule { + id = "cleanup-old-versions" + status = "Enabled" + + filter {} + + expiration { + days = 90 + } + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + ############# -<<<<<<< Updated upstream -# IAM Policy for Sandbox Workflow Operations -<<<<<<< Updated upstream -# The github-actions-finishline user needs these permissions for sandbox-up.yml: -# - RDS snapshot operations (create from prod, copy cross-region) -# - Secrets Manager reads (pull prod secrets) -# - EB describe (pull prod non-secret config) -======= -# Attaches to the manually-created github-actions-finishline user. -# Grants permissions needed by sandbox-up.yml that the user doesn't already have. ->>>>>>> Stashed changes -############# -resource "aws_iam_user_policy" "github_actions_sandbox" { - name = "sandbox-workflow-permissions" - user = "github-actions-finishline" -======= # IAM Permissions for github-actions-finishline -# Attaches managed policies so Terraform can provision sandbox resources. -# Also adds an inline policy for operations specific to the sandbox workflow. +# The user was created manually outside of Terraform. +# Managed policies cover provisioning sandbox resources via Terraform. +# The inline policy covers sandbox-workflow-specific operations. ############# locals { github_actions_user = "github-actions-finishline" managed_policies = { - ec2 = "arn:aws:iam::aws:policy/AmazonEC2FullAccess" - rds = "arn:aws:iam::aws:policy/AmazonRDSFullAccess" - iam = "arn:aws:iam::aws:policy/IAMFullAccess" - eb = "arn:aws:iam::aws:policy/AdministratorAccess-AWSElasticBeanstalk" - s3 = "arn:aws:iam::aws:policy/AmazonS3FullAccess" - cloudwatch = "arn:aws:iam::aws:policy/CloudWatchFullAccess" - amplify = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify" - logs = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" + ec2 = "arn:aws:iam::aws:policy/AmazonEC2FullAccess" + rds = "arn:aws:iam::aws:policy/AmazonRDSFullAccess" + iam = "arn:aws:iam::aws:policy/IAMFullAccess" + eb = "arn:aws:iam::aws:policy/AdministratorAccess-AWSElasticBeanstalk" + s3 = "arn:aws:iam::aws:policy/AmazonS3FullAccess" + cloudwatch = "arn:aws:iam::aws:policy/CloudWatchFullAccess" + amplify = "arn:aws:iam::aws:policy/AdministratorAccess-Amplify" + logs = "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess" } } @@ -203,17 +199,11 @@ resource "aws_iam_user_policy_attachment" "github_actions_managed" { resource "aws_iam_user_policy" "github_actions_sandbox" { name = "sandbox-workflow-permissions" user = local.github_actions_user ->>>>>>> Stashed changes policy = jsonencode({ Version = "2012-10-17" Statement = [ { -<<<<<<< Updated upstream -<<<<<<< Updated upstream -======= -======= ->>>>>>> Stashed changes Sid = "TerraformStateS3" Effect = "Allow" Action = [ @@ -238,10 +228,6 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "arn:aws:dynamodb:us-east-1:830877454256:table/finishline-terraform-locks" }, { -<<<<<<< Updated upstream ->>>>>>> Stashed changes -======= ->>>>>>> Stashed changes Sid = "RDSSnapshotOperations" Effect = "Allow" Action = [ @@ -255,21 +241,6 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "*" }, { -<<<<<<< Updated upstream -<<<<<<< Updated upstream - Sid = "SecretsManagerReadProd" - Effect = "Allow" - Action = [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ] - Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" - }, - { -======= ->>>>>>> Stashed changes -======= ->>>>>>> Stashed changes Sid = "KMSForSnapshotCopy" Effect = "Allow" Action = [ @@ -291,11 +262,6 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { } }, { -<<<<<<< Updated upstream -<<<<<<< Updated upstream -======= -======= ->>>>>>> Stashed changes Sid = "SecretsManagerReadProd" Effect = "Allow" Action = [ @@ -305,10 +271,6 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { Resource = "arn:aws:secretsmanager:us-east-1:830877454256:secret:finishline/production/*" }, { -<<<<<<< Updated upstream ->>>>>>> Stashed changes -======= ->>>>>>> Stashed changes Sid = "ElasticBeanstalkDescribeProd" Effect = "Allow" Action = [ @@ -320,24 +282,3 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { ] }) } - -# Clean up old EB versions after 90 days -resource "aws_s3_bucket_lifecycle_configuration" "eb_versions" { - bucket = aws_s3_bucket.eb_versions.id - - rule { - id = "cleanup-old-versions" - status = "Enabled" - - filter {} - - expiration { - days = 90 - } - - noncurrent_version_expiration { - noncurrent_days = 30 - } - } -} - diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index 372f8413ef..2d017288e5 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -146,11 +146,9 @@ module "elasticbeanstalk" { module "frontend" { source = "../../modules/amplify-frontend" - project_name = "finishline" - environment = "sandbox" - github_repository = "https://github.com/Northeastern-Electric-Racing/FinishLine" - github_access_token = var.github_access_token - main_branch_name = "develop" + project_name = "finishline" + environment = "sandbox" + main_branch_name = "develop" backend_api_url = module.elasticbeanstalk.environment_endpoint_url domain_name = "" diff --git a/infrastructure/environments/sandbox/variables.tf b/infrastructure/environments/sandbox/variables.tf index 21645ccd97..e94825e996 100644 --- a/infrastructure/environments/sandbox/variables.tf +++ b/infrastructure/environments/sandbox/variables.tf @@ -14,13 +14,6 @@ variable "db_master_password" { # In CI/CD: generated once and stored in GitHub Actions secrets } -variable "github_access_token" { - description = "GitHub personal access token for Amplify to access the repository" - type = string - sensitive = true - default = "" -} - variable "snapshot_identifier" { description = "RDS snapshot ID to restore from (taken from prod by the CI/CD pipeline before spin-up)" type = string diff --git a/infrastructure/modules/amplify-frontend/main.tf b/infrastructure/modules/amplify-frontend/main.tf index 1b491cb1b9..dd9fe54ca3 100644 --- a/infrastructure/modules/amplify-frontend/main.tf +++ b/infrastructure/modules/amplify-frontend/main.tf @@ -4,11 +4,7 @@ # Amplify App ############# resource "aws_amplify_app" "frontend" { - name = "${var.project_name}-${var.environment}-frontend" - repository = var.github_repository - - # GitHub access token for webhook setup — optional; omit for public repos - access_token = var.github_access_token != "" ? var.github_access_token : null + name = "${var.project_name}-${var.environment}-frontend" # Build specification build_spec = <<-EOT diff --git a/infrastructure/modules/amplify-frontend/variables.tf b/infrastructure/modules/amplify-frontend/variables.tf index 68ab5f80fe..f6fc849b5c 100644 --- a/infrastructure/modules/amplify-frontend/variables.tf +++ b/infrastructure/modules/amplify-frontend/variables.tf @@ -10,17 +10,6 @@ variable "environment" { type = string } -variable "github_repository" { - description = "GitHub repository URL (e.g., https://github.com/username/repo)" - type = string -} - -variable "github_access_token" { - description = "GitHub personal access token for repository access" - type = string - sensitive = true - default = "" -} variable "main_branch_name" { description = "Name of the main branch to deploy" From 51db25d20df8ed1d52c0404b37880883fd0987f2 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 16:02:52 -0400 Subject: [PATCH 15/29] fix: use us-east-2 for Amplify CLI calls (app is in sandbox region) --- .github/workflows/sandbox-up.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index c9b670278c..0fac8cddfc 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -192,7 +192,7 @@ jobs: DEPLOY=$(aws amplify create-deployment \ --app-id "$APP_ID" \ --branch-name develop \ - --region us-east-1) + --region us-east-2) JOB_ID=$(echo "$DEPLOY" | jq -r '.jobId') UPLOAD_URL=$(echo "$DEPLOY" | jq -r '.zipUploadUrl') @@ -206,7 +206,7 @@ jobs: --app-id "$APP_ID" \ --branch-name develop \ --job-id "$JOB_ID" \ - --region us-east-1 + --region us-east-2 echo "Waiting for Amplify deployment..." while true; do @@ -214,7 +214,7 @@ jobs: --app-id "$APP_ID" \ --branch-name develop \ --job-id "$JOB_ID" \ - --region us-east-1 \ + --region us-east-2 \ --query "job.summary.status" \ --output text) echo " status: $STATUS" From ae2c719725b8cb3bd794527fb5b7ed861b3e234a Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 16:42:34 -0400 Subject: [PATCH 16/29] vite --- .github/workflows/sandbox-up.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 0fac8cddfc..4c997d3161 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -181,6 +181,12 @@ jobs: cd "$GITHUB_WORKSPACE" yarn install --frozen-lockfile yarn workspace shared build + + # Set Vite env vars required at build time + EB_URL=$(cd infrastructure/environments/sandbox && terraform output -raw eb_environment_url) + export VITE_REACT_APP_BACKEND_URL="http://$EB_URL" + export VITE_REACT_APP_GOOGLE_AUTH_CLIENT_ID="$TF_VAR_google_client_id" + yarn workspace frontend build # Zip the built artifacts (files at root, not nested) From 5385a066158f0cc2b389f2682892f5a79a41616d Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 18:28:38 -0400 Subject: [PATCH 17/29] subdomain --- infrastructure/bootstrap/main.tf | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index a0f7c25f42..d79a285f90 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -278,6 +278,18 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { "elasticbeanstalk:DescribeEnvironments" ] Resource = "*" + }, + { + Sid = "Route53SandboxDNS" + Effect = "Allow" + Action = [ + "route53:ChangeResourceRecordSets", + "route53:ListHostedZonesByName", + "route53:ListHostedZones", + "route53:GetHostedZone", + "route53:GetChange" + ] + Resource = "*" } ] }) From 6479619cf78dba5e039dfe2bd4a953254b0384ee Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 18:30:50 -0400 Subject: [PATCH 18/29] subdomain pt2 --- .github/workflows/sandbox-up.yml | 89 ++++++++++++++++--- infrastructure/environments/sandbox/main.tf | 18 ++-- .../modules/amplify-frontend/main.tf | 2 +- .../modules/amplify-frontend/outputs.tf | 10 +++ .../modules/amplify-frontend/variables.tf | 6 ++ 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 4c997d3161..6839cec50a 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -172,6 +172,79 @@ jobs: TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} run: terraform apply -auto-approve + - name: Configure Amplify custom domain DNS + working-directory: infrastructure/environments/sandbox + run: | + APP_ID=$(terraform output -raw amplify_app_id) + DOMAIN="sandbox.finishlinebyner.com" + + # Get cert verification and subdomain routing records from Amplify + DOMAIN_ASSOC=$(aws amplify get-domain-association \ + --app-id "$APP_ID" \ + --domain-name "$DOMAIN" \ + --region us-east-2) + + # Parse cert verification record: "_abc.domain. CNAME _xyz.acm-validations.aws." + CERT_RECORD=$(echo "$DOMAIN_ASSOC" | jq -r '.domainAssociation.certificateVerificationDNSRecord') + CERT_NAME=$(echo "$CERT_RECORD" | awk '{print $1}') + CERT_VALUE=$(echo "$CERT_RECORD" | awk '{print $3}') + + # Parse subdomain routing record: "CNAME branch.appid.amplifyapp.com" + SUB_RECORD=$(echo "$DOMAIN_ASSOC" | jq -r '.domainAssociation.subDomains[0].dnsRecord') + SUB_VALUE=$(echo "$SUB_RECORD" | awk '{print $NF}') + + # Get Route53 hosted zone ID + ZONE_ID=$(aws route53 list-hosted-zones-by-name \ + --dns-name "finishlinebyner.com." \ + --query "HostedZones[0].Id" \ + --output text | sed 's|/hostedzone/||') + + echo "Zone ID: $ZONE_ID" + echo "Cert verification: $CERT_NAME CNAME $CERT_VALUE" + echo "Subdomain routing: $DOMAIN CNAME $SUB_VALUE" + + # Upsert both DNS records + aws route53 change-resource-record-sets \ + --hosted-zone-id "$ZONE_ID" \ + --change-batch "{ + \"Changes\": [ + { + \"Action\": \"UPSERT\", + \"ResourceRecordSet\": { + \"Name\": \"$CERT_NAME\", + \"Type\": \"CNAME\", + \"TTL\": 300, + \"ResourceRecords\": [{\"Value\": \"$CERT_VALUE\"}] + } + }, + { + \"Action\": \"UPSERT\", + \"ResourceRecordSet\": { + \"Name\": \"$DOMAIN.\", + \"Type\": \"CNAME\", + \"TTL\": 300, + \"ResourceRecords\": [{\"Value\": \"$SUB_VALUE\"}] + } + } + ] + }" + + echo "Waiting for Amplify domain to become active..." + while true; do + STATUS=$(aws amplify get-domain-association \ + --app-id "$APP_ID" \ + --domain-name "$DOMAIN" \ + --region us-east-2 \ + --query "domainAssociation.domainStatus" \ + --output text) + echo " domain status: $STATUS" + case "$STATUS" in + AVAILABLE) echo "Domain is active."; break ;; + FAILED) echo "Domain verification failed."; exit 1 ;; + *) sleep 30 ;; + esac + done + - name: Build and deploy frontend to Amplify working-directory: infrastructure/environments/sandbox run: | @@ -240,18 +313,12 @@ jobs: echo "frontend_url=$(terraform output -raw frontend_url)" >> "$GITHUB_OUTPUT" - name: Deploy app to sandbox EB - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} run: | - # Log into ECR (prod repo, us-east-1) to get the registry URL - ECR_REGISTRY=$(aws ecr get-login-password --region us-east-1 | \ - docker login --username AWS --password-stdin \ - "$(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com" \ - 2>/dev/null && \ - echo "$(aws sts get-caller-identity --query Account --output text).dkr.ecr.us-east-1.amazonaws.com") - ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com" + + aws ecr get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin "$ECR_REGISTRY" ECR_REPOSITORY="finishline-production" # Use the latest prod image — same image tested in sandbox = same image that goes to prod @@ -290,5 +357,7 @@ jobs: run: | echo "Sandbox is up!" echo "" - echo "Frontend: ${{ steps.urls.outputs.frontend_url }}" + echo "Frontend: https://sandbox.finishlinebyner.com" echo "Backend: ${{ steps.urls.outputs.eb_url }}" + echo "" + echo "Register https://sandbox.finishlinebyner.com in Google OAuth once, then it works on every spin-up." diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index 2d017288e5..1eb11ecaa3 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -149,12 +149,20 @@ module "frontend" { project_name = "finishline" environment = "sandbox" main_branch_name = "develop" - backend_api_url = module.elasticbeanstalk.environment_endpoint_url + backend_api_url = module.elasticbeanstalk.environment_endpoint_url + + domain_name = "sandbox.finishlinebyner.com" + enable_pull_request_preview = false + enable_auto_branch_creation = false + create_webhook = false + # The workflow polls for domain ACTIVE status after applying DNS records + wait_for_domain_verification = false +} - domain_name = "" - enable_pull_request_preview = false - enable_auto_branch_creation = false - create_webhook = false +# Route53 hosted zone for finishlinebyner.com +data "aws_route53_zone" "main" { + name = "finishlinebyner.com" + private_zone = false } ############# diff --git a/infrastructure/modules/amplify-frontend/main.tf b/infrastructure/modules/amplify-frontend/main.tf index dd9fe54ca3..18ac62f6ba 100644 --- a/infrastructure/modules/amplify-frontend/main.tf +++ b/infrastructure/modules/amplify-frontend/main.tf @@ -118,7 +118,7 @@ resource "aws_amplify_domain_association" "main" { domain_name = var.domain_name # Wait for DNS propagation - wait_for_verification = true + wait_for_verification = var.wait_for_domain_verification # Main branch subdomain configuration sub_domain { diff --git a/infrastructure/modules/amplify-frontend/outputs.tf b/infrastructure/modules/amplify-frontend/outputs.tf index 4d89b6c8b9..5722aef11f 100644 --- a/infrastructure/modules/amplify-frontend/outputs.tf +++ b/infrastructure/modules/amplify-frontend/outputs.tf @@ -36,5 +36,15 @@ output "webhook_url" { sensitive = true } +output "certificate_verification_dns_record" { + description = "DNS record string Amplify needs added to Route53 for certificate verification" + value = var.domain_name != "" ? aws_amplify_domain_association.main[0].certificate_verification_dns_record : null +} + +output "subdomain_dns_record" { + description = "DNS record string Amplify needs added to Route53 to route the custom domain" + value = var.domain_name != "" ? aws_amplify_domain_association.main[0].sub_domain[0].dns_record : null +} + # Data source to get current region data "aws_region" "current" {} diff --git a/infrastructure/modules/amplify-frontend/variables.tf b/infrastructure/modules/amplify-frontend/variables.tf index f6fc849b5c..b1112a9c5b 100644 --- a/infrastructure/modules/amplify-frontend/variables.tf +++ b/infrastructure/modules/amplify-frontend/variables.tf @@ -69,3 +69,9 @@ variable "create_webhook" { type = bool default = false } + +variable "wait_for_domain_verification" { + description = "Whether Terraform should block until Amplify domain verification completes. Set false for sandbox where the workflow handles the wait." + type = bool + default = true +} From 59c4f7cfb7ef4293abc1e65b3c97238bf3f388bb Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 26 Jun 2026 18:47:04 -0400 Subject: [PATCH 19/29] fix destroy --- infrastructure/modules/amplify-frontend/outputs.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/amplify-frontend/outputs.tf b/infrastructure/modules/amplify-frontend/outputs.tf index 5722aef11f..4b7dab8fc5 100644 --- a/infrastructure/modules/amplify-frontend/outputs.tf +++ b/infrastructure/modules/amplify-frontend/outputs.tf @@ -43,7 +43,7 @@ output "certificate_verification_dns_record" { output "subdomain_dns_record" { description = "DNS record string Amplify needs added to Route53 to route the custom domain" - value = var.domain_name != "" ? aws_amplify_domain_association.main[0].sub_domain[0].dns_record : null + value = var.domain_name != "" ? tolist(aws_amplify_domain_association.main[0].sub_domain)[0].dns_record : null } # Data source to get current region From c66f3fa2b61b058ca923c107afc04a4244216378 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 2 Jul 2026 22:17:29 -0400 Subject: [PATCH 20/29] change domain name --- .github/workflows/sandbox-up.yml | 6 +++--- infrastructure/environments/sandbox/main.tf | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 6839cec50a..4e7c4001fc 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -176,7 +176,7 @@ jobs: working-directory: infrastructure/environments/sandbox run: | APP_ID=$(terraform output -raw amplify_app_id) - DOMAIN="sandbox.finishlinebyner.com" + DOMAIN="qa.finishlinebyner.com" # Get cert verification and subdomain routing records from Amplify DOMAIN_ASSOC=$(aws amplify get-domain-association \ @@ -357,7 +357,7 @@ jobs: run: | echo "Sandbox is up!" echo "" - echo "Frontend: https://sandbox.finishlinebyner.com" + echo "Frontend: https://qa.finishlinebyner.com" echo "Backend: ${{ steps.urls.outputs.eb_url }}" echo "" - echo "Register https://sandbox.finishlinebyner.com in Google OAuth once, then it works on every spin-up." + echo "Register https://qa.finishlinebyner.com in Google OAuth once, then it works on every spin-up." diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index 1eb11ecaa3..749efc2a53 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -151,7 +151,7 @@ module "frontend" { main_branch_name = "develop" backend_api_url = module.elasticbeanstalk.environment_endpoint_url - domain_name = "sandbox.finishlinebyner.com" + domain_name = "qa.finishlinebyner.com" enable_pull_request_preview = false enable_auto_branch_creation = false create_webhook = false From c6abcb9894cc295c6dffcedb23555a3f1d1638d1 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 2 Jul 2026 22:42:16 -0400 Subject: [PATCH 21/29] grant route53:ListTagsForResource to github-actions-finishline --- infrastructure/bootstrap/main.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index d79a285f90..9e20d28521 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -287,7 +287,8 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { "route53:ListHostedZonesByName", "route53:ListHostedZones", "route53:GetHostedZone", - "route53:GetChange" + "route53:GetChange", + "route53:ListTagsForResource" ] Resource = "*" } From 815aa35ae952c759d703f52ef58326da527a532a Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 10:09:08 -0400 Subject: [PATCH 22/29] remove redundant manual Route53 DNS step for Amplify custom domain Amplify auto-manages DNS records when the hosted zone is in the same AWS account, so the manual UPSERT was conflicting with Amplify's own auto-created record on every run. --- .github/workflows/sandbox-up.yml | 56 +++----------------------------- 1 file changed, 4 insertions(+), 52 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 4e7c4001fc..24d0d5f8cf 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -172,63 +172,15 @@ jobs: TF_VAR_snapshot_identifier: ${{ steps.snapshot_copy.outputs.snapshot_id }} run: terraform apply -auto-approve - - name: Configure Amplify custom domain DNS + - name: Wait for Amplify custom domain to become active working-directory: infrastructure/environments/sandbox run: | APP_ID=$(terraform output -raw amplify_app_id) DOMAIN="qa.finishlinebyner.com" - # Get cert verification and subdomain routing records from Amplify - DOMAIN_ASSOC=$(aws amplify get-domain-association \ - --app-id "$APP_ID" \ - --domain-name "$DOMAIN" \ - --region us-east-2) - - # Parse cert verification record: "_abc.domain. CNAME _xyz.acm-validations.aws." - CERT_RECORD=$(echo "$DOMAIN_ASSOC" | jq -r '.domainAssociation.certificateVerificationDNSRecord') - CERT_NAME=$(echo "$CERT_RECORD" | awk '{print $1}') - CERT_VALUE=$(echo "$CERT_RECORD" | awk '{print $3}') - - # Parse subdomain routing record: "CNAME branch.appid.amplifyapp.com" - SUB_RECORD=$(echo "$DOMAIN_ASSOC" | jq -r '.domainAssociation.subDomains[0].dnsRecord') - SUB_VALUE=$(echo "$SUB_RECORD" | awk '{print $NF}') - - # Get Route53 hosted zone ID - ZONE_ID=$(aws route53 list-hosted-zones-by-name \ - --dns-name "finishlinebyner.com." \ - --query "HostedZones[0].Id" \ - --output text | sed 's|/hostedzone/||') - - echo "Zone ID: $ZONE_ID" - echo "Cert verification: $CERT_NAME CNAME $CERT_VALUE" - echo "Subdomain routing: $DOMAIN CNAME $SUB_VALUE" - - # Upsert both DNS records - aws route53 change-resource-record-sets \ - --hosted-zone-id "$ZONE_ID" \ - --change-batch "{ - \"Changes\": [ - { - \"Action\": \"UPSERT\", - \"ResourceRecordSet\": { - \"Name\": \"$CERT_NAME\", - \"Type\": \"CNAME\", - \"TTL\": 300, - \"ResourceRecords\": [{\"Value\": \"$CERT_VALUE\"}] - } - }, - { - \"Action\": \"UPSERT\", - \"ResourceRecordSet\": { - \"Name\": \"$DOMAIN.\", - \"Type\": \"CNAME\", - \"TTL\": 300, - \"ResourceRecords\": [{\"Value\": \"$SUB_VALUE\"}] - } - } - ] - }" - + # finishlinebyner.com's hosted zone is in this same AWS account, so Amplify + # automatically creates and manages the cert-verification and subdomain + # routing DNS records itself — no manual Route53 writes needed here. echo "Waiting for Amplify domain to become active..." while true; do STATUS=$(aws amplify get-domain-association \ From 76767315a945163e38674109c5453d62433135dd Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 11:48:24 -0400 Subject: [PATCH 23/29] enable HTTPS for sandbox backend to fix mixed-content login failure --- infrastructure/environments/sandbox/main.tf | 45 +++++++++++++++---- .../modules/elasticbeanstalk/main.tf | 1 + .../modules/elasticbeanstalk/variables.tf | 6 +++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/infrastructure/environments/sandbox/main.tf b/infrastructure/environments/sandbox/main.tf index 749efc2a53..7f0afc60dd 100644 --- a/infrastructure/environments/sandbox/main.tf +++ b/infrastructure/environments/sandbox/main.tf @@ -86,6 +86,31 @@ module "rds" { master_password = var.db_master_password } +############# +# Backend domain (fixed CNAME prefix so the ACM cert's validation record and +# Route53 CNAME can be created before the EB environment exists, avoiding a +# dependency cycle between module.dns and module.elasticbeanstalk) +############# +locals { + eb_cname_prefix = "finishline-sandbox" + eb_cname = "${local.eb_cname_prefix}.us-east-2.elasticbeanstalk.com" + backend_domain = "qa.api.finishlinebyner.com" +} + +############# +# DNS Module (backend HTTPS cert + Route53 record) +############# +module "dns" { + source = "../../modules/dns" + + project_name = "finishline" + environment = "sandbox" + hosted_zone_name = "finishlinebyner.com" + frontend_domain = "qa.finishlinebyner.com" + backend_domain = local.backend_domain + backend_cname = local.eb_cname +} + ############# # Elastic Beanstalk Module ############# @@ -107,14 +132,16 @@ module "elasticbeanstalk" { instance_security_group_id = module.network.eb_instance_security_group_id alb_security_group_id = module.network.alb_security_group_id - # Sandbox-specific: single instance, fast deploys, no HTTPS - min_instance_count = 1 - max_instance_count = 1 - deployment_policy = "AllAtOnce" - enable_https = false - health_check_path = "/health" - log_retention_days = 7 - ec2_key_name = "finishline-sandbox" + # Sandbox-specific: single instance, fast deploys + min_instance_count = 1 + max_instance_count = 1 + deployment_policy = "AllAtOnce" + cname_prefix = local.eb_cname_prefix + enable_https = true + ssl_certificate_arn = module.dns.backend_certificate_arn + health_check_path = "/health" + log_retention_days = 7 + ec2_key_name = "finishline-sandbox" environment_variables = { DATABASE_URL = module.rds.database_url @@ -149,7 +176,7 @@ module "frontend" { project_name = "finishline" environment = "sandbox" main_branch_name = "develop" - backend_api_url = module.elasticbeanstalk.environment_endpoint_url + backend_api_url = module.dns.backend_url domain_name = "qa.finishlinebyner.com" enable_pull_request_preview = false diff --git a/infrastructure/modules/elasticbeanstalk/main.tf b/infrastructure/modules/elasticbeanstalk/main.tf index 89d8469305..8c24e9255e 100644 --- a/infrastructure/modules/elasticbeanstalk/main.tf +++ b/infrastructure/modules/elasticbeanstalk/main.tf @@ -33,6 +33,7 @@ resource "aws_elastic_beanstalk_environment" "main" { application = aws_elastic_beanstalk_application.main.name solution_stack_name = var.solution_stack_name != "" ? var.solution_stack_name : data.aws_elastic_beanstalk_solution_stack.docker.name tier = "WebServer" + cname_prefix = var.cname_prefix != "" ? var.cname_prefix : null ##################### # VPC Configuration diff --git a/infrastructure/modules/elasticbeanstalk/variables.tf b/infrastructure/modules/elasticbeanstalk/variables.tf index 041da68107..60559c410d 100644 --- a/infrastructure/modules/elasticbeanstalk/variables.tf +++ b/infrastructure/modules/elasticbeanstalk/variables.tf @@ -111,6 +111,12 @@ variable "ssl_certificate_arn" { default = "" } +variable "cname_prefix" { + description = "Fixed CNAME prefix for the environment (e.g. 'finishline-sandbox'). Leave empty to let AWS assign a random one. Set this when a backend ACM cert needs a CNAME known before the environment exists, to avoid a dependency cycle." + type = string + default = "" +} + variable "ec2_key_name" { description = "Name of the EC2 key pair for SSH access (leave empty to disable SSH)" type = string From 79d3719e48fc26eb5e6717b87500f732acd315db Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 12:41:13 -0400 Subject: [PATCH 24/29] grant acm:RequestCertificate and related actions to github-actions-finishline --- infrastructure/bootstrap/main.tf | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 9e20d28521..b3fa9622b9 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -291,6 +291,17 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { "route53:ListTagsForResource" ] Resource = "*" + }, + { + Sid = "ACMSandboxCerts" + Effect = "Allow" + Action = [ + "acm:RequestCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToResource", + "acm:RemoveTagsFromResource" + ] + Resource = "*" } ] }) From fa2b42d0033a2247c5e513f084364697918339fb Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 13:14:42 -0400 Subject: [PATCH 25/29] grant acm:AddTagsToCertificate and RemoveTagsFromCertificate to github-actions-finishline --- infrastructure/bootstrap/main.tf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index b3fa9622b9..17b0372bf3 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -299,7 +299,9 @@ resource "aws_iam_user_policy" "github_actions_sandbox" { "acm:RequestCertificate", "acm:DeleteCertificate", "acm:AddTagsToResource", - "acm:RemoveTagsFromResource" + "acm:RemoveTagsFromResource", + "acm:AddTagsToCertificate", + "acm:RemoveTagsFromCertificate" ] Resource = "*" } From ced1a38b215a3af2937995e059fe1c724c55161f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 13:53:30 -0400 Subject: [PATCH 26/29] fix malformed backend URL causing mixed-content/cert-mismatch on login --- .github/workflows/sandbox-up.yml | 5 +++-- infrastructure/environments/sandbox/outputs.tf | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 24d0d5f8cf..1f94a65d4f 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -208,8 +208,9 @@ jobs: yarn workspace shared build # Set Vite env vars required at build time - EB_URL=$(cd infrastructure/environments/sandbox && terraform output -raw eb_environment_url) - export VITE_REACT_APP_BACKEND_URL="http://$EB_URL" + # Must use the custom domain, not the raw EB CNAME: the backend's + # cert only matches qa.api.finishlinebyner.com. + export VITE_REACT_APP_BACKEND_URL=$(cd infrastructure/environments/sandbox && terraform output -raw backend_url) export VITE_REACT_APP_GOOGLE_AUTH_CLIENT_ID="$TF_VAR_google_client_id" yarn workspace frontend build diff --git a/infrastructure/environments/sandbox/outputs.tf b/infrastructure/environments/sandbox/outputs.tf index 552ced20d7..4be2d815a0 100644 --- a/infrastructure/environments/sandbox/outputs.tf +++ b/infrastructure/environments/sandbox/outputs.tf @@ -39,6 +39,11 @@ output "eb_environment_url" { value = module.elasticbeanstalk.environment_endpoint_url } +output "backend_url" { + description = "HTTPS custom-domain URL for the sandbox backend (what the frontend should call; the EB environment's cert only matches this domain, not the raw EB CNAME)" + value = module.dns.backend_url +} + output "eb_cname" { description = "Raw CNAME of the sandbox EB environment" value = module.elasticbeanstalk.environment_cname From 34bd975e1998fde96a677af15deec82939a2d957 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 16:38:20 -0400 Subject: [PATCH 27/29] =?UTF-8?q?fix=20CORS/JWT=20middleware=20for=20sandb?= =?UTF-8?q?ox=20to=20use=20real=20Google=20auth=20like=20prod=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backend/index.ts b/src/backend/index.ts index 0df1277a2d..729d53894b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -33,9 +33,12 @@ const app = express(); const port = process.env.PORT || 3001; const isProd = process.env.NODE_ENV === 'production'; +// Sandbox's frontend is a real production build, so it always uses the real Google +// login flow (never the dev login) and needs the same auth/CORS handling as prod. +const usesRealGoogleAuth = isProd || process.env.NODE_ENV === 'sandbox'; // cors options -const allowedHeaders = isProd ? prodHeaders : '*'; +const allowedHeaders = usesRealGoogleAuth ? prodHeaders : '*'; // Build list of allowed origins const allowedOrigins = [ @@ -85,7 +88,7 @@ app.use(express.json()); app.use(cors(options)); // ensure each request is authorized using JWT -app.use(isProd ? requireJwtProd : requireJwtDev); +app.use(usesRealGoogleAuth ? requireJwtProd : requireJwtDev); // get user and organization app.use(getUserAndOrganization); From aa076d4ae72ad07d57941fd9c7702f8306d1eaeb Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 3 Jul 2026 16:52:36 -0400 Subject: [PATCH 28/29] build --- src/backend/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/index.ts b/src/backend/index.ts index 729d53894b..ef668c80cc 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -35,7 +35,7 @@ const port = process.env.PORT || 3001; const isProd = process.env.NODE_ENV === 'production'; // Sandbox's frontend is a real production build, so it always uses the real Google // login flow (never the dev login) and needs the same auth/CORS handling as prod. -const usesRealGoogleAuth = isProd || process.env.NODE_ENV === 'sandbox'; +const usesRealGoogleAuth = isProd || (process.env.NODE_ENV as string) === 'sandbox'; // cors options const allowedHeaders = usesRealGoogleAuth ? prodHeaders : '*'; From 16d2289035c080a7894f1a21f58f93af314c955f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 5 Jul 2026 12:08:04 -0400 Subject: [PATCH 29/29] build and push candidate image from branch to sandbox instead of reusing stale prod :latest --- .github/workflows/sandbox-up.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sandbox-up.yml b/.github/workflows/sandbox-up.yml index 1f94a65d4f..7c91a11e42 100644 --- a/.github/workflows/sandbox-up.yml +++ b/.github/workflows/sandbox-up.yml @@ -265,7 +265,7 @@ jobs: echo "eb_cname=$(terraform output -raw eb_cname)" >> "$GITHUB_OUTPUT" echo "frontend_url=$(terraform output -raw frontend_url)" >> "$GITHUB_OUTPUT" - - name: Deploy app to sandbox EB + - name: Build and push candidate image to prod ECR repo run: | ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com" @@ -274,12 +274,25 @@ jobs: docker login --username AWS --password-stdin "$ECR_REGISTRY" ECR_REPOSITORY="finishline-production" - # Use the latest prod image — same image tested in sandbox = same image that goes to prod + # Build from this branch and push to the SAME prod ECR repo (no separate + # sandbox repo — ECR has no concept of environments) under a distinct tag, + # so sandbox tests the exact image that would be promoted to prod, rather + # than whatever was last actually deployed to prod. + SANDBOX_IMAGE_TAG="sandbox-${{ github.sha }}" + docker build --tag "$ECR_REGISTRY/$ECR_REPOSITORY:$SANDBOX_IMAGE_TAG" . + docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$SANDBOX_IMAGE_TAG" + + echo "SANDBOX_IMAGE_TAG=$SANDBOX_IMAGE_TAG" >> "$GITHUB_ENV" + echo "ECR_REGISTRY=$ECR_REGISTRY" >> "$GITHUB_ENV" + echo "ECR_REPOSITORY=$ECR_REPOSITORY" >> "$GITHUB_ENV" + + - name: Deploy app to sandbox EB + run: | cat > Dockerrun.aws.json <