diff --git a/.github/actions/deploy-ecs-service/action.yml b/.github/actions/deploy-ecs-service/action.yml new file mode 100644 index 0000000..ca4969a --- /dev/null +++ b/.github/actions/deploy-ecs-service/action.yml @@ -0,0 +1,129 @@ +name: Deploy ECS service +description: Register a digest-pinned ECS task definition and update one Happy Post ECS service. + +inputs: + aws_account_id: + description: AWS account ID that owns the ECR repository. + required: true + aws_region: + description: AWS region. + required: true + ecs_cluster: + description: ECS cluster name. + required: true + component: + description: Component being deployed. + required: true + repository: + description: ECR repository name. + required: true + service: + description: ECS service name. + required: true + family: + description: ECS task-definition family. + required: true + container: + description: ECS container name. + required: true + target_group: + description: ALB target group name. + required: true + smoke_url: + description: Public smoke-test URL. + required: true + image_digest: + description: Verified image digest to deploy. + required: true + source_commit: + description: Source commit associated with the image digest. + required: true + +runs: + using: composite + steps: + - name: Register and deploy task definition + shell: bash + env: + AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} + AWS_REGION: ${{ inputs.aws_region }} + ECS_CLUSTER: ${{ inputs.ecs_cluster }} + COMPONENT: ${{ inputs.component }} + REPOSITORY: ${{ inputs.repository }} + SERVICE: ${{ inputs.service }} + FAMILY: ${{ inputs.family }} + CONTAINER: ${{ inputs.container }} + TARGET_GROUP_NAME: ${{ inputs.target_group }} + SMOKE_URL: ${{ inputs.smoke_url }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + deployment_started=false + previous_task_definition="" + + rollback_after_failure() { + exit_code=$? + trap - ERR + if [[ "$deployment_started" == true ]]; then + echo "Deployment verification failed; restoring $previous_task_definition" + aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$previous_task_definition" > /dev/null + aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" + curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null + fi + exit "$exit_code" + } + trap rollback_after_failure ERR + + service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --output json)" + test "$(jq -r '.services | length' <<< "$service_details")" = "1" + test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" + previous_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" + [[ "$previous_task_definition" == *":task-definition/$FAMILY:"* ]] + + target_group_arn="$(aws elbv2 describe-target-groups --names "$TARGET_GROUP_NAME" --query 'TargetGroups[0].TargetGroupArn' --output text)" + target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" + jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null + curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null + aws ecs tag-resource --resource-arn "$previous_task_definition" --tags key=HappyPostDeploymentStatus,value=known-good + + aws ecs describe-task-definition --task-definition "$previous_task_definition" --include TAGS --output json > current-task-definition.json + jq -e --arg container "$CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' current-task-definition.json > /dev/null + + image_uri="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${REPOSITORY}@${IMAGE_DIGEST}" + jq --arg container "$CONTAINER" --arg image "$image_uri" --arg version "$IMAGE_DIGEST" ' + .taskDefinition + | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy, .deregisteredAt) + | .containerDefinitions |= map( + if .name == $container then + .image = $image + | .environment |= map(if .name == "APP_VERSION" then .value = $version else . end) + else . end + ) + ' current-task-definition.json > candidate-task-definition.json + + candidate_task_definition="$(aws ecs register-task-definition \ + --cli-input-json file://candidate-task-definition.json \ + --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value="$COMPONENT" key=HappyPostDeploymentStatus,value=candidate key=SourceCommit,value="$SOURCE_COMMIT" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text)" + [[ "$candidate_task_definition" == *":task-definition/$FAMILY:"* ]] + deployment_started=true + aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$candidate_task_definition" > /dev/null + aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" + + deployed_task_definition="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --query 'services[0].taskDefinition' --output text)" + test "$deployed_task_definition" = "$candidate_task_definition" + target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" + jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null + curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null + aws ecs tag-resource --resource-arn "$candidate_task_definition" --tags key=HappyPostDeploymentStatus,value=known-good + + { + echo "## ECS deployment succeeded" + echo "- Component: $COMPONENT" + echo "- Previous task definition: $previous_task_definition" + echo "- Deployed task definition: $candidate_task_definition" + echo "- Image digest: $IMAGE_DIGEST" + echo "- Source commit: $SOURCE_COMMIT" + echo "- Smoke test: $SMOKE_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/publish-ecr-image/action.yml b/.github/actions/publish-ecr-image/action.yml new file mode 100644 index 0000000..d8998b7 --- /dev/null +++ b/.github/actions/publish-ecr-image/action.yml @@ -0,0 +1,56 @@ +name: Publish ECR image +description: Push a locally built component image to ECR and write an immutable digest handoff file. + +inputs: + aws_account_id: + description: AWS account ID that owns the ECR repository. + required: true + aws_region: + description: AWS region. + required: true + component: + description: Component name. + required: true + repository: + description: ECR repository name. + required: true + source_commit: + description: Source commit SHA used as the immutable image tag. + required: true + +outputs: + digest: + description: ECR image digest. + value: ${{ steps.publish.outputs.digest }} + +runs: + using: composite + steps: + - name: Push image and write digest handoff + id: publish + shell: bash + env: + AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} + AWS_REGION: ${{ inputs.aws_region }} + COMPONENT: ${{ inputs.component }} + REPOSITORY: ${{ inputs.repository }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + image_tag="sha-$SOURCE_COMMIT" + registry="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + destination="$registry/$REPOSITORY:$image_tag" + + aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$registry" + docker tag "happy-post-${COMPONENT}:${SOURCE_COMMIT}" "$destination" + docker push "$destination" + + digest="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$image_tag" --query 'imageDetails[0].imageDigest' --output text)" + [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + { + echo "component=$COMPONENT" + echo "repository=$REPOSITORY" + echo "digest=$digest" + echo "source_commit=$SOURCE_COMMIT" + } > image-digest.txt diff --git a/.github/actions/resolve-ecs-service/action.yml b/.github/actions/resolve-ecs-service/action.yml new file mode 100644 index 0000000..aea1e85 --- /dev/null +++ b/.github/actions/resolve-ecs-service/action.yml @@ -0,0 +1,68 @@ +name: Resolve ECS service +description: Resolve Happy Post ECS service metadata from a selected component. + +inputs: + component: + description: Component to resolve. + required: true + +outputs: + repository: + description: ECR repository name. + value: ${{ steps.resolve.outputs.repository }} + service: + description: ECS service name. + value: ${{ steps.resolve.outputs.service }} + family: + description: ECS task-definition family. + value: ${{ steps.resolve.outputs.family }} + container: + description: ECS container name. + value: ${{ steps.resolve.outputs.container }} + target_group: + description: ALB target group name. + value: ${{ steps.resolve.outputs.target_group }} + smoke_url: + description: Public smoke-test URL. + value: ${{ steps.resolve.outputs.smoke_url }} + +runs: + using: composite + steps: + - name: Resolve service metadata + id: resolve + shell: bash + env: + COMPONENT: ${{ inputs.component }} + run: | + case "$COMPONENT" in + backend) + repository=happy-post-sandbox-backend + service=happy-post-sandbox-backend + family=happy-post-sandbox-backend + container=backend + target_group=happy-post-sandbox-backend + smoke_url=https://happy-post.asksafe.ai/backend/healthz + ;; + frontend) + repository=happy-post-sandbox-frontend + service=happy-post-sandbox-frontend + family=happy-post-sandbox-frontend + container=frontend + target_group=happy-post-sandbox-frontend + smoke_url=https://happy-post.asksafe.ai/healthz + ;; + *) + echo "Unsupported component: $COMPONENT" + exit 1 + ;; + esac + + { + echo "repository=$repository" + echo "service=$service" + echo "family=$family" + echo "container=$container" + echo "target_group=$target_group" + echo "smoke_url=$smoke_url" + } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/resolve-service-bootstrap-target/action.yml b/.github/actions/resolve-service-bootstrap-target/action.yml new file mode 100644 index 0000000..817af00 --- /dev/null +++ b/.github/actions/resolve-service-bootstrap-target/action.yml @@ -0,0 +1,51 @@ +name: Resolve service bootstrap target +description: Resolve Terraform service root metadata for initial ECS service bootstrap. + +inputs: + component: + description: Component to bootstrap. + required: true + +outputs: + working_directory: + description: Terraform root directory. + value: ${{ steps.resolve.outputs.working_directory }} + repository: + description: ECR repository name. + value: ${{ steps.resolve.outputs.repository }} + variable_name: + description: Terraform image digest variable name. + value: ${{ steps.resolve.outputs.variable_name }} + +runs: + using: composite + steps: + - name: Resolve service bootstrap metadata + id: resolve + shell: bash + env: + COMPONENT: ${{ inputs.component }} + run: | + case "$COMPONENT" in + backend) + working_directory=infra/terraform/stacks/backend-service + repository=happy-post-sandbox-backend + variable_name=backend_image_digest + ;; + frontend) + working_directory=infra/terraform/stacks/frontend-service + repository=happy-post-sandbox-frontend + variable_name=frontend_image_digest + ;; + *) + echo "Unsupported component: $COMPONENT" + exit 1 + ;; + esac + + test -d "$working_directory" + { + echo "working_directory=$working_directory" + echo "repository=$repository" + echo "variable_name=$variable_name" + } >> "$GITHUB_OUTPUT" diff --git a/.github/actions/resolve-terraform-root/action.yml b/.github/actions/resolve-terraform-root/action.yml new file mode 100644 index 0000000..b332eb7 --- /dev/null +++ b/.github/actions/resolve-terraform-root/action.yml @@ -0,0 +1,39 @@ +name: Resolve Terraform root +description: Resolve an approved Happy Post Terraform target to its root directory. + +inputs: + target: + description: Canonical Terraform target. + required: true + +outputs: + working_directory: + description: Terraform root directory. + value: ${{ steps.resolve.outputs.working_directory }} + +runs: + using: composite + steps: + - name: Resolve Terraform root + id: resolve + shell: bash + env: + TERRAFORM_TARGET: ${{ inputs.target }} + run: | + case "$TERRAFORM_TARGET" in + network) working_directory=infra/terraform/foundations/network ;; + security-identity) working_directory=infra/terraform/foundations/security-identity ;; + platform) working_directory=infra/terraform/foundations/platform ;; + edge) working_directory=infra/terraform/foundations/edge ;; + data) working_directory=infra/terraform/stacks/data ;; + backend-service) working_directory=infra/terraform/stacks/backend-service ;; + frontend-service) working_directory=infra/terraform/stacks/frontend-service ;; + observability) working_directory=infra/terraform/stacks/observability ;; + *) + echo "Unsupported Terraform target: $TERRAFORM_TARGET" + exit 1 + ;; + esac + + test -d "$working_directory" + echo "working_directory=$working_directory" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/rollback-ecs-service/action.yml b/.github/actions/rollback-ecs-service/action.yml new file mode 100644 index 0000000..84ba2e2 --- /dev/null +++ b/.github/actions/rollback-ecs-service/action.yml @@ -0,0 +1,82 @@ +name: Roll back ECS service +description: Restore one Happy Post ECS service to its immediately preceding known-good task definition. + +inputs: + ecs_cluster: + description: ECS cluster name. + required: true + component: + description: Component being rolled back. + required: true + service: + description: ECS service name. + required: true + family: + description: ECS task-definition family. + required: true + container: + description: ECS container name. + required: true + target_group: + description: ALB target group name. + required: true + smoke_url: + description: Public smoke-test URL. + required: true + +runs: + using: composite + steps: + - name: Restore immediately preceding known-good revision + shell: bash + env: + ECS_CLUSTER: ${{ inputs.ecs_cluster }} + COMPONENT: ${{ inputs.component }} + SERVICE: ${{ inputs.service }} + FAMILY: ${{ inputs.family }} + CONTAINER: ${{ inputs.container }} + TARGET_GROUP_NAME: ${{ inputs.target_group }} + SMOKE_URL: ${{ inputs.smoke_url }} + run: | + service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --output json)" + test "$(jq -r '.services | length' <<< "$service_details")" = "1" + test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" + current_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" + [[ "$current_task_definition" == *":task-definition/$FAMILY:"* ]] + current_revision="${current_task_definition##*:}" + [[ "$current_revision" =~ ^[0-9]+$ ]] + + aws ecs list-task-definitions --family-prefix "$FAMILY" --status ACTIVE --sort DESC --query 'taskDefinitionArns' --output json > task-definitions.json + rollback_task_definition="" + while IFS= read -r task_definition; do + revision="${task_definition##*:}" + (( revision < current_revision )) || continue + status="$(aws ecs list-tags-for-resource --resource-arn "$task_definition" --query "tags[?key=='HappyPostDeploymentStatus'].value | [0]" --output text)" + if [[ "$status" == "known-good" ]]; then + rollback_task_definition="$task_definition" + break + fi + done < <(jq -r '.[]' task-definitions.json) + test -n "$rollback_task_definition" + + aws ecs describe-task-definition --task-definition "$rollback_task_definition" --output json > rollback-task-definition.json + test "$(jq -r '.taskDefinition.family' rollback-task-definition.json)" = "$FAMILY" + jq -e --arg container "$CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' rollback-task-definition.json > /dev/null + + aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$rollback_task_definition" > /dev/null + aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" + deployed_task_definition="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --query 'services[0].taskDefinition' --output text)" + test "$deployed_task_definition" = "$rollback_task_definition" + + target_group_arn="$(aws elbv2 describe-target-groups --names "$TARGET_GROUP_NAME" --query 'TargetGroups[0].TargetGroupArn' --output text)" + target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" + jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null + curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null + + { + echo "## ECS rollback succeeded" + echo "- Component: $COMPONENT" + echo "- Previous task definition: $current_task_definition" + echo "- Restored task definition: $rollback_task_definition" + echo "- Smoke test: $SMOKE_URL" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/run-ecs-database-migration/action.yml b/.github/actions/run-ecs-database-migration/action.yml new file mode 100644 index 0000000..e9b8b5e --- /dev/null +++ b/.github/actions/run-ecs-database-migration/action.yml @@ -0,0 +1,120 @@ +name: Run ECS database migration +description: Register and run a one-off ECS Fargate task for backend Alembic migrations. + +inputs: + aws_account_id: + description: AWS account ID that owns the ECR repository. + required: true + aws_region: + description: AWS region. + required: true + ecs_cluster: + description: ECS cluster name. + required: true + backend_service: + description: Existing backend ECS service name. + required: true + backend_repository: + description: Backend ECR repository name. + required: true + backend_container: + description: Backend container name in the task definition. + required: true + migration_family: + description: ECS task-definition family to register for migrations. + required: true + image_digest: + description: Verified backend image digest to run. + required: true + source_commit: + description: Source commit associated with the image digest. + required: true + +runs: + using: composite + steps: + - name: Register migration task definition + id: register + shell: bash + env: + AWS_ACCOUNT_ID: ${{ inputs.aws_account_id }} + AWS_REGION: ${{ inputs.aws_region }} + ECS_CLUSTER: ${{ inputs.ecs_cluster }} + BACKEND_SERVICE: ${{ inputs.backend_service }} + BACKEND_REPOSITORY: ${{ inputs.backend_repository }} + BACKEND_CONTAINER: ${{ inputs.backend_container }} + MIGRATION_FAMILY: ${{ inputs.migration_family }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$BACKEND_SERVICE" --output json)" + test "$(jq -r '.services | length' <<< "$service_details")" = "1" + test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" + backend_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" + [[ "$backend_task_definition" == *":task-definition/happy-post-sandbox-backend:"* ]] + + aws ecs describe-task-definition --task-definition "$backend_task_definition" --output json > backend-task-definition.json + jq -e --arg container "$BACKEND_CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' backend-task-definition.json > /dev/null + + image_uri="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${BACKEND_REPOSITORY}@${IMAGE_DIGEST}" + jq --arg family "$MIGRATION_FAMILY" --arg container "$BACKEND_CONTAINER" --arg image "$image_uri" --arg version "$IMAGE_DIGEST" ' + .taskDefinition + | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy, .deregisteredAt) + | .family = $family + | .containerDefinitions |= map( + if .name == $container then + .image = $image + | .command = ["alembic", "upgrade", "head"] + | .environment |= map(if .name == "APP_VERSION" then .value = $version else . end) + | del(.healthCheck) + else . end + ) + ' backend-task-definition.json > migration-task-definition.json + + migration_task_definition="$(aws ecs register-task-definition \ + --cli-input-json file://migration-task-definition.json \ + --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value=backend key=Purpose,value=database-migration key=SourceCommit,value="$SOURCE_COMMIT" \ + --query 'taskDefinition.taskDefinitionArn' \ + --output text)" + [[ "$migration_task_definition" == *":task-definition/$MIGRATION_FAMILY:"* ]] + echo "task_definition=$migration_task_definition" >> "$GITHUB_OUTPUT" + + - name: Run migration task and wait for completion + shell: bash + env: + ECS_CLUSTER: ${{ inputs.ecs_cluster }} + BACKEND_SERVICE: ${{ inputs.backend_service }} + BACKEND_CONTAINER: ${{ inputs.backend_container }} + TASK_DEFINITION: ${{ steps.register.outputs.task_definition }} + run: | + service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$BACKEND_SERVICE" --output json)" + network_configuration="$(jq -c '{awsvpcConfiguration: .services[0].networkConfiguration.awsvpcConfiguration}' <<< "$service_details")" + test "$(jq -r '.awsvpcConfiguration.assignPublicIp' <<< "$network_configuration")" = "DISABLED" + + run_result="$(aws ecs run-task \ + --cluster "$ECS_CLUSTER" \ + --launch-type FARGATE \ + --task-definition "$TASK_DEFINITION" \ + --network-configuration "$network_configuration" \ + --started-by "happy-post-database-migration-${GITHUB_RUN_ID}" \ + --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value=backend key=Purpose,value=database-migration \ + --output json)" + jq -e '.failures | length == 0' <<< "$run_result" > /dev/null + task_arn="$(jq -r '.tasks[0].taskArn' <<< "$run_result")" + [[ "$task_arn" == arn:*:ecs:*:*:task/* ]] + + aws ecs wait tasks-stopped --cluster "$ECS_CLUSTER" --tasks "$task_arn" + task_details="$(aws ecs describe-tasks --cluster "$ECS_CLUSTER" --tasks "$task_arn" --output json)" + exit_code="$(jq -r --arg container "$BACKEND_CONTAINER" '.tasks[0].containers[] | select(.name == $container) | .exitCode' <<< "$task_details")" + stopped_reason="$(jq -r '.tasks[0].stoppedReason // "not provided"' <<< "$task_details")" + container_reason="$(jq -r --arg container "$BACKEND_CONTAINER" '.tasks[0].containers[] | select(.name == $container) | .reason // "not provided"' <<< "$task_details")" + test "$exit_code" = "0" + + { + echo "## Database migration succeeded" + echo "- Task definition: $TASK_DEFINITION" + echo "- Migration task: $task_arn" + echo "- Exit code: $exit_code" + echo "- Stopped reason: $stopped_reason" + echo "- Container reason: $container_reason" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/select-image-components/action.yml b/.github/actions/select-image-components/action.yml new file mode 100644 index 0000000..63d9cb5 --- /dev/null +++ b/.github/actions/select-image-components/action.yml @@ -0,0 +1,73 @@ +name: Select image components +description: Select backend/frontend images to publish from a push or manual dispatch event. + +inputs: + event_name: + description: GitHub event name. + required: true + requested_component: + description: Manually requested component for workflow_dispatch. + required: false + default: "" + base_sha: + description: Push event base SHA. + required: true + head_sha: + description: Push event head SHA. + required: true + +outputs: + backend: + description: Whether to publish the backend image. + value: ${{ steps.select.outputs.backend }} + frontend: + description: Whether to publish the frontend image. + value: ${{ steps.select.outputs.frontend }} + +runs: + using: composite + steps: + - name: Select changed components + id: select + shell: bash + env: + EVENT_NAME: ${{ inputs.event_name }} + REQUESTED_COMPONENT: ${{ inputs.requested_component }} + BASE_SHA: ${{ inputs.base_sha }} + HEAD_SHA: ${{ inputs.head_sha }} + run: | + backend=false + frontend=false + + case "$EVENT_NAME" in + workflow_dispatch) + test "$GITHUB_REF" = "refs/heads/main" + case "$REQUESTED_COMPONENT" in + backend) backend=true ;; + frontend) frontend=true ;; + all) backend=true; frontend=true ;; + *) echo "Unsupported component: $REQUESTED_COMPONENT"; exit 1 ;; + esac + ;; + push) + if [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + changed_files="$(git diff-tree --no-commit-id --name-only -r "$HEAD_SHA")" + else + changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + fi + if grep -Eq '^backend/' <<< "$changed_files"; then + backend=true + fi + if grep -Eq '^frontend/' <<< "$changed_files"; then + frontend=true + fi + ;; + *) + echo "Unsupported event: $EVENT_NAME" + exit 1 + ;; + esac + + echo "backend=$backend" >> "$GITHUB_OUTPUT" + echo "frontend=$frontend" >> "$GITHUB_OUTPUT" + echo "Selected components: backend=$backend frontend=$frontend" diff --git a/.github/actions/verify-ecr-image/action.yml b/.github/actions/verify-ecr-image/action.yml new file mode 100644 index 0000000..38bd088 --- /dev/null +++ b/.github/actions/verify-ecr-image/action.yml @@ -0,0 +1,27 @@ +name: Verify ECR image +description: Verify that an immutable image digest exists in ECR with its matching source-commit tag. + +inputs: + repository: + description: ECR repository name. + required: true + image_digest: + description: Expected SHA-256 image digest. + required: true + source_commit: + description: Full lowercase source commit SHA expected in the sha- tag. + required: true + +runs: + using: composite + steps: + - name: Verify image digest and source tag + shell: bash + env: + REPOSITORY: ${{ inputs.repository }} + IMAGE_DIGEST: ${{ inputs.image_digest }} + SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + image_details="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$IMAGE_DIGEST" --output json)" + test "$(jq -r '.imageDetails[0].imageDigest' <<< "$image_details")" = "$IMAGE_DIGEST" + jq -e --arg tag "sha-$SOURCE_COMMIT" '.imageDetails[0].imageTags | index($tag) != null' <<< "$image_details" > /dev/null diff --git a/.github/workflows/database-migration.yml b/.github/workflows/database-migration.yml index ea88d73..cf3e91e 100644 --- a/.github/workflows/database-migration.yml +++ b/.github/workflows/database-migration.yml @@ -20,6 +20,7 @@ permissions: contents: read env: + AWS_ACCOUNT_ID: "893794041695" AWS_REGION: ap-southeast-2 ECS_DEPLOY_ROLE_ARN: arn:aws:iam::893794041695:role/happy-post-sandbox-ecs-deploy ECS_CLUSTER: happy-post-sandbox-cluster @@ -80,85 +81,21 @@ jobs: role-session-name: happy-post-database-migration-${{ github.run_id }} - name: Verify backend image digest provenance - env: - IMAGE_DIGEST: ${{ inputs.image_digest }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - image_details="$(aws ecr describe-images --repository-name "$BACKEND_REPOSITORY" --image-ids imageDigest="$IMAGE_DIGEST" --output json)" - test "$(jq -r '.imageDetails[0].imageDigest' <<< "$image_details")" = "$IMAGE_DIGEST" - jq -e --arg tag "sha-$SOURCE_COMMIT" '.imageDetails[0].imageTags | index($tag) != null' <<< "$image_details" > /dev/null - - - name: Register migration task definition - id: register - env: - IMAGE_DIGEST: ${{ inputs.image_digest }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$BACKEND_SERVICE" --output json)" - test "$(jq -r '.services | length' <<< "$service_details")" = "1" - test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" - backend_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" - [[ "$backend_task_definition" == *":task-definition/happy-post-sandbox-backend:"* ]] - - aws ecs describe-task-definition --task-definition "$backend_task_definition" --output json > backend-task-definition.json - jq -e --arg container "$BACKEND_CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' backend-task-definition.json > /dev/null - - image_uri="893794041695.dkr.ecr.${AWS_REGION}.amazonaws.com/${BACKEND_REPOSITORY}@${IMAGE_DIGEST}" - jq --arg family "$MIGRATION_FAMILY" --arg container "$BACKEND_CONTAINER" --arg image "$image_uri" --arg version "$IMAGE_DIGEST" ' - .taskDefinition - | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy, .deregisteredAt) - | .family = $family - | .containerDefinitions |= map( - if .name == $container then - .image = $image - | .command = ["alembic", "upgrade", "head"] - | .environment |= map(if .name == "APP_VERSION" then .value = $version else . end) - | del(.healthCheck) - else . end - ) - ' backend-task-definition.json > migration-task-definition.json - - migration_task_definition="$(aws ecs register-task-definition \ - --cli-input-json file://migration-task-definition.json \ - --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value=backend key=Purpose,value=database-migration key=SourceCommit,value="$SOURCE_COMMIT" \ - --query 'taskDefinition.taskDefinitionArn' \ - --output text)" - [[ "$migration_task_definition" == *":task-definition/$MIGRATION_FAMILY:"* ]] - echo "task_definition=$migration_task_definition" >> "$GITHUB_OUTPUT" - - - name: Run migration task and wait for completion - env: - TASK_DEFINITION: ${{ steps.register.outputs.task_definition }} - run: | - service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$BACKEND_SERVICE" --output json)" - network_configuration="$(jq -c '{awsvpcConfiguration: .services[0].networkConfiguration.awsvpcConfiguration}' <<< "$service_details")" - test "$(jq -r '.awsvpcConfiguration.assignPublicIp' <<< "$network_configuration")" = "DISABLED" - - run_result="$(aws ecs run-task \ - --cluster "$ECS_CLUSTER" \ - --launch-type FARGATE \ - --task-definition "$TASK_DEFINITION" \ - --network-configuration "$network_configuration" \ - --started-by "happy-post-database-migration-${GITHUB_RUN_ID}" \ - --enable-execute-command false \ - --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value=backend key=Purpose,value=database-migration \ - --output json)" - jq -e '.failures | length == 0' <<< "$run_result" > /dev/null - task_arn="$(jq -r '.tasks[0].taskArn' <<< "$run_result")" - [[ "$task_arn" == arn:*:ecs:*:*:task/* ]] - - aws ecs wait tasks-stopped --cluster "$ECS_CLUSTER" --tasks "$task_arn" - task_details="$(aws ecs describe-tasks --cluster "$ECS_CLUSTER" --tasks "$task_arn" --output json)" - exit_code="$(jq -r --arg container "$BACKEND_CONTAINER" '.tasks[0].containers[] | select(.name == $container) | .exitCode' <<< "$task_details")" - stopped_reason="$(jq -r '.tasks[0].stoppedReason // "not provided"' <<< "$task_details")" - container_reason="$(jq -r --arg container "$BACKEND_CONTAINER" '.tasks[0].containers[] | select(.name == $container) | .reason // "not provided"' <<< "$task_details")" - test "$exit_code" = "0" + uses: ./.github/actions/verify-ecr-image + with: + repository: ${{ env.BACKEND_REPOSITORY }} + image_digest: ${{ inputs.image_digest }} + source_commit: ${{ inputs.source_commit }} - { - echo "## Database migration succeeded" - echo "- Task definition: $TASK_DEFINITION" - echo "- Migration task: $task_arn" - echo "- Exit code: $exit_code" - echo "- Stopped reason: $stopped_reason" - echo "- Container reason: $container_reason" - } >> "$GITHUB_STEP_SUMMARY" + - name: Run backend database migration + uses: ./.github/actions/run-ecs-database-migration + with: + aws_account_id: ${{ env.AWS_ACCOUNT_ID }} + aws_region: ${{ env.AWS_REGION }} + ecs_cluster: ${{ env.ECS_CLUSTER }} + backend_service: ${{ env.BACKEND_SERVICE }} + backend_repository: ${{ env.BACKEND_REPOSITORY }} + backend_container: ${{ env.BACKEND_CONTAINER }} + migration_family: ${{ env.MIGRATION_FAMILY }} + image_digest: ${{ inputs.image_digest }} + source_commit: ${{ inputs.source_commit }} diff --git a/.github/workflows/ecs-deploy.yml b/.github/workflows/ecs-deploy.yml index 50e113d..b6a4e70 100644 --- a/.github/workflows/ecs-deploy.yml +++ b/.github/workflows/ecs-deploy.yml @@ -27,6 +27,7 @@ permissions: contents: read env: + AWS_ACCOUNT_ID: "893794041695" AWS_REGION: ap-southeast-2 ECS_DEPLOY_ROLE_ARN: arn:aws:iam::893794041695:role/happy-post-sandbox-ecs-deploy ECS_CLUSTER: happy-post-sandbox-cluster @@ -79,36 +80,9 @@ jobs: - name: Resolve selected service id: service - env: - COMPONENT: ${{ inputs.component }} - run: | - case "$COMPONENT" in - backend) - repository=happy-post-sandbox-backend - service=happy-post-sandbox-backend - family=happy-post-sandbox-backend - container=backend - target_group=happy-post-sandbox-backend - smoke_url=https://happy-post.asksafe.ai/backend/healthz - ;; - frontend) - repository=happy-post-sandbox-frontend - service=happy-post-sandbox-frontend - family=happy-post-sandbox-frontend - container=frontend - target_group=happy-post-sandbox-frontend - smoke_url=https://happy-post.asksafe.ai/healthz - ;; - *) echo "Unsupported component: $COMPONENT"; exit 1 ;; - esac - { - echo "repository=$repository" - echo "service=$service" - echo "family=$family" - echo "container=$container" - echo "target_group=$target_group" - echo "smoke_url=$smoke_url" - } >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-ecs-service + with: + component: ${{ inputs.component }} - name: Configure short-lived AWS credentials uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 @@ -118,90 +92,24 @@ jobs: role-session-name: happy-post-ecs-deploy-${{ github.run_id }} - name: Verify digest provenance in selected ECR repository - env: - REPOSITORY: ${{ steps.service.outputs.repository }} - IMAGE_DIGEST: ${{ inputs.image_digest }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - image_details="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$IMAGE_DIGEST" --output json)" - test "$(jq -r '.imageDetails[0].imageDigest' <<< "$image_details")" = "$IMAGE_DIGEST" - jq -e --arg tag "sha-$SOURCE_COMMIT" '.imageDetails[0].imageTags | index($tag) != null' <<< "$image_details" > /dev/null + uses: ./.github/actions/verify-ecr-image + with: + repository: ${{ steps.service.outputs.repository }} + image_digest: ${{ inputs.image_digest }} + source_commit: ${{ inputs.source_commit }} - name: Register and deploy a digest-pinned task definition - id: deploy - env: - COMPONENT: ${{ inputs.component }} - IMAGE_DIGEST: ${{ inputs.image_digest }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - REPOSITORY: ${{ steps.service.outputs.repository }} - SERVICE: ${{ steps.service.outputs.service }} - FAMILY: ${{ steps.service.outputs.family }} - CONTAINER: ${{ steps.service.outputs.container }} - TARGET_GROUP_NAME: ${{ steps.service.outputs.target_group }} - SMOKE_URL: ${{ steps.service.outputs.smoke_url }} - run: | - deployment_started=false - previous_task_definition="" - - rollback_after_failure() { - exit_code=$? - trap - ERR - if [[ "$deployment_started" == true ]]; then - echo "Deployment verification failed; restoring $previous_task_definition" - aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$previous_task_definition" > /dev/null - aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" - curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null - fi - exit "$exit_code" - } - trap rollback_after_failure ERR - - service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --output json)" - test "$(jq -r '.services | length' <<< "$service_details")" = "1" - test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" - previous_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" - [[ "$previous_task_definition" == *":task-definition/$FAMILY:"* ]] - - target_group_arn="$(aws elbv2 describe-target-groups --names "$TARGET_GROUP_NAME" --query 'TargetGroups[0].TargetGroupArn' --output text)" - target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" - jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null - curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null - aws ecs tag-resource --resource-arn "$previous_task_definition" --tags key=HappyPostDeploymentStatus,value=known-good - - aws ecs describe-task-definition --task-definition "$previous_task_definition" --include TAGS --output json > current-task-definition.json - jq -e --arg container "$CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' current-task-definition.json > /dev/null - - image_uri="893794041695.dkr.ecr.${AWS_REGION}.amazonaws.com/${REPOSITORY}@${IMAGE_DIGEST}" - jq --arg container "$CONTAINER" --arg image "$image_uri" --arg version "$IMAGE_DIGEST" ' - .taskDefinition - | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy, .deregisteredAt) - | .containerDefinitions |= map( - if .name == $container then - .image = $image - | .environment |= map(if .name == "APP_VERSION" then .value = $version else . end) - else . end - ) - ' current-task-definition.json > candidate-task-definition.json - - candidate_task_definition="$(aws ecs register-task-definition --cli-input-json file://candidate-task-definition.json --tags key=Project,value=happy-post key=Environment,value=sandbox key=Component,value="$COMPONENT" key=HappyPostDeploymentStatus,value=candidate key=SourceCommit,value="$SOURCE_COMMIT" --query 'taskDefinition.taskDefinitionArn' --output text)" - [[ "$candidate_task_definition" == *":task-definition/$FAMILY:"* ]] - deployment_started=true - aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$candidate_task_definition" > /dev/null - aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" - - deployed_task_definition="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --query 'services[0].taskDefinition' --output text)" - test "$deployed_task_definition" = "$candidate_task_definition" - target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" - jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null - curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null - aws ecs tag-resource --resource-arn "$candidate_task_definition" --tags key=HappyPostDeploymentStatus,value=known-good - - { - echo "## ECS deployment succeeded" - echo "- Component: $COMPONENT" - echo "- Previous task definition: $previous_task_definition" - echo "- Deployed task definition: $candidate_task_definition" - echo "- Image digest: $IMAGE_DIGEST" - echo "- Source commit: $SOURCE_COMMIT" - echo "- Smoke test: $SMOKE_URL" - } >> "$GITHUB_STEP_SUMMARY" + uses: ./.github/actions/deploy-ecs-service + with: + aws_account_id: ${{ env.AWS_ACCOUNT_ID }} + aws_region: ${{ env.AWS_REGION }} + ecs_cluster: ${{ env.ECS_CLUSTER }} + component: ${{ inputs.component }} + repository: ${{ steps.service.outputs.repository }} + service: ${{ steps.service.outputs.service }} + family: ${{ steps.service.outputs.family }} + container: ${{ steps.service.outputs.container }} + target_group: ${{ steps.service.outputs.target_group }} + smoke_url: ${{ steps.service.outputs.smoke_url }} + image_digest: ${{ inputs.image_digest }} + source_commit: ${{ inputs.source_commit }} diff --git a/.github/workflows/ecs-rollback.yml b/.github/workflows/ecs-rollback.yml index 52f629a..8831c73 100644 --- a/.github/workflows/ecs-rollback.yml +++ b/.github/workflows/ecs-rollback.yml @@ -53,33 +53,9 @@ jobs: - name: Resolve selected service id: service - env: - COMPONENT: ${{ inputs.component }} - run: | - case "$COMPONENT" in - backend) - service=happy-post-sandbox-backend - family=happy-post-sandbox-backend - container=backend - target_group=happy-post-sandbox-backend - smoke_url=https://happy-post.asksafe.ai/backend/healthz - ;; - frontend) - service=happy-post-sandbox-frontend - family=happy-post-sandbox-frontend - container=frontend - target_group=happy-post-sandbox-frontend - smoke_url=https://happy-post.asksafe.ai/healthz - ;; - *) echo "Unsupported component: $COMPONENT"; exit 1 ;; - esac - { - echo "service=$service" - echo "family=$family" - echo "container=$container" - echo "target_group=$target_group" - echo "smoke_url=$smoke_url" - } >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-ecs-service + with: + component: ${{ inputs.component }} - name: Configure short-lived AWS credentials uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 @@ -89,53 +65,12 @@ jobs: role-session-name: happy-post-ecs-rollback-${{ github.run_id }} - name: Restore the immediately preceding known-good revision - env: - COMPONENT: ${{ inputs.component }} - SERVICE: ${{ steps.service.outputs.service }} - FAMILY: ${{ steps.service.outputs.family }} - CONTAINER: ${{ steps.service.outputs.container }} - TARGET_GROUP_NAME: ${{ steps.service.outputs.target_group }} - SMOKE_URL: ${{ steps.service.outputs.smoke_url }} - run: | - service_details="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --output json)" - test "$(jq -r '.services | length' <<< "$service_details")" = "1" - test "$(jq -r '.services[0].status' <<< "$service_details")" = "ACTIVE" - current_task_definition="$(jq -r '.services[0].taskDefinition' <<< "$service_details")" - [[ "$current_task_definition" == *":task-definition/$FAMILY:"* ]] - current_revision="${current_task_definition##*:}" - [[ "$current_revision" =~ ^[0-9]+$ ]] - - aws ecs list-task-definitions --family-prefix "$FAMILY" --status ACTIVE --sort DESC --query 'taskDefinitionArns' --output json > task-definitions.json - rollback_task_definition="" - while IFS= read -r task_definition; do - revision="${task_definition##*:}" - (( revision < current_revision )) || continue - status="$(aws ecs list-tags-for-resource --resource-arn "$task_definition" --query "tags[?key=='HappyPostDeploymentStatus'].value | [0]" --output text)" - if [[ "$status" == "known-good" ]]; then - rollback_task_definition="$task_definition" - break - fi - done < <(jq -r '.[]' task-definitions.json) - test -n "$rollback_task_definition" - - aws ecs describe-task-definition --task-definition "$rollback_task_definition" --output json > rollback-task-definition.json - test "$(jq -r '.taskDefinition.family' rollback-task-definition.json)" = "$FAMILY" - jq -e --arg container "$CONTAINER" '[.taskDefinition.containerDefinitions[] | select(.name == $container)] | length == 1' rollback-task-definition.json > /dev/null - - aws ecs update-service --cluster "$ECS_CLUSTER" --service "$SERVICE" --task-definition "$rollback_task_definition" > /dev/null - aws ecs wait services-stable --cluster "$ECS_CLUSTER" --services "$SERVICE" - deployed_task_definition="$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$SERVICE" --query 'services[0].taskDefinition' --output text)" - test "$deployed_task_definition" = "$rollback_task_definition" - - target_group_arn="$(aws elbv2 describe-target-groups --names "$TARGET_GROUP_NAME" --query 'TargetGroups[0].TargetGroupArn' --output text)" - target_health="$(aws elbv2 describe-target-health --target-group-arn "$target_group_arn" --output json)" - jq -e '[.TargetHealthDescriptions[].TargetHealth.State] | any(. == "healthy")' <<< "$target_health" > /dev/null - curl --fail --silent --show-error --max-time 20 "$SMOKE_URL" > /dev/null - - { - echo "## ECS rollback succeeded" - echo "- Component: $COMPONENT" - echo "- Previous task definition: $current_task_definition" - echo "- Restored task definition: $rollback_task_definition" - echo "- Smoke test: $SMOKE_URL" - } >> "$GITHUB_STEP_SUMMARY" + uses: ./.github/actions/rollback-ecs-service + with: + ecs_cluster: ${{ env.ECS_CLUSTER }} + component: ${{ inputs.component }} + service: ${{ steps.service.outputs.service }} + family: ${{ steps.service.outputs.family }} + container: ${{ steps.service.outputs.container }} + target_group: ${{ steps.service.outputs.target_group }} + smoke_url: ${{ steps.service.outputs.smoke_url }} diff --git a/.github/workflows/image-publish.yml b/.github/workflows/image-publish.yml index 72e5d88..ce31a1d 100644 --- a/.github/workflows/image-publish.yml +++ b/.github/workflows/image-publish.yml @@ -42,43 +42,12 @@ jobs: - name: Select changed components id: detect - env: - EVENT_NAME: ${{ github.event_name }} - REQUESTED_COMPONENT: ${{ inputs.component || '' }} - BASE_SHA: ${{ github.event.before }} - HEAD_SHA: ${{ github.sha }} - run: | - backend=false - frontend=false - case "$EVENT_NAME" in - workflow_dispatch) - test "$GITHUB_REF" = "refs/heads/main" - case "$REQUESTED_COMPONENT" in - backend) backend=true ;; - frontend) frontend=true ;; - all) backend=true; frontend=true ;; - *) echo "Unsupported component: $REQUESTED_COMPONENT"; exit 1 ;; - esac - ;; - push) - if [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then - changed_files="$(git diff-tree --no-commit-id --name-only -r "$HEAD_SHA")" - else - changed_files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" - fi - if grep -Eq '^backend/' <<< "$changed_files"; then - backend=true - fi - if grep -Eq '^frontend/' <<< "$changed_files"; then - frontend=true - fi - ;; - *) echo "Unsupported event: $EVENT_NAME"; exit 1 ;; - esac - - echo "backend=$backend" >> "$GITHUB_OUTPUT" - echo "frontend=$frontend" >> "$GITHUB_OUTPUT" - echo "Selected components: backend=$backend frontend=$frontend" + uses: ./.github/actions/select-image-components + with: + event_name: ${{ github.event_name }} + requested_component: ${{ inputs.component || '' }} + base_sha: ${{ github.event.before }} + head_sha: ${{ github.sha }} publish-backend: name: Build, scan, and publish backend @@ -118,29 +87,13 @@ jobs: - name: Push backend image to its ECR repository id: publish - env: - REPOSITORY: happy-post-sandbox-backend - IMAGE_TAG: sha-${{ github.sha }} - run: | - registry="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - destination="$registry/$REPOSITORY:$IMAGE_TAG" - aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$registry" - docker tag "happy-post-backend:${GITHUB_SHA}" "$destination" - docker push "$destination" - digest="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$IMAGE_TAG" --query 'imageDetails[0].imageDigest' --output text)" - [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - - name: Record backend digest handoff - env: - DIGEST: ${{ steps.publish.outputs.digest }} - run: | - { - echo "component=backend" - echo "repository=happy-post-sandbox-backend" - echo "digest=$DIGEST" - echo "source_commit=$GITHUB_SHA" - } > image-digest.txt + uses: ./.github/actions/publish-ecr-image + with: + aws_account_id: ${{ env.AWS_ACCOUNT_ID }} + aws_region: ${{ env.AWS_REGION }} + component: backend + repository: happy-post-sandbox-backend + source_commit: ${{ github.sha }} - name: Upload backend digest handoff uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -188,29 +141,13 @@ jobs: - name: Push frontend image to its ECR repository id: publish - env: - REPOSITORY: happy-post-sandbox-frontend - IMAGE_TAG: sha-${{ github.sha }} - run: | - registry="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" - destination="$registry/$REPOSITORY:$IMAGE_TAG" - aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$registry" - docker tag "happy-post-frontend:${GITHUB_SHA}" "$destination" - docker push "$destination" - digest="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageTag="$IMAGE_TAG" --query 'imageDetails[0].imageDigest' --output text)" - [[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - - name: Record frontend digest handoff - env: - DIGEST: ${{ steps.publish.outputs.digest }} - run: | - { - echo "component=frontend" - echo "repository=happy-post-sandbox-frontend" - echo "digest=$DIGEST" - echo "source_commit=$GITHUB_SHA" - } > image-digest.txt + uses: ./.github/actions/publish-ecr-image + with: + aws_account_id: ${{ env.AWS_ACCOUNT_ID }} + aws_region: ${{ env.AWS_REGION }} + component: frontend + repository: happy-post-sandbox-frontend + source_commit: ${{ github.sha }} - name: Upload frontend digest handoff uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/service-bootstrap.yml b/.github/workflows/service-bootstrap.yml index d80a2d0..38392eb 100644 --- a/.github/workflows/service-bootstrap.yml +++ b/.github/workflows/service-bootstrap.yml @@ -75,26 +75,9 @@ jobs: - name: Resolve selected service root id: service - env: - COMPONENT: ${{ inputs.component }} - run: | - case "$COMPONENT" in - backend) - working_directory=infra/terraform/stacks/backend-service - repository=happy-post-sandbox-backend - variable_name=backend_image_digest - ;; - frontend) - working_directory=infra/terraform/stacks/frontend-service - repository=happy-post-sandbox-frontend - variable_name=frontend_image_digest - ;; - *) echo "Unsupported component: $COMPONENT"; exit 1 ;; - esac - test -d "$working_directory" - echo "working_directory=$working_directory" >> "$GITHUB_OUTPUT" - echo "repository=$repository" >> "$GITHUB_OUTPUT" - echo "variable_name=$variable_name" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-service-bootstrap-target + with: + component: ${{ inputs.component }} - name: Configure short-lived AWS credentials uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 @@ -104,16 +87,11 @@ jobs: role-session-name: happy-post-service-bootstrap-${{ github.run_id }} - name: Verify digest belongs to the selected ECR repository - env: - REPOSITORY: ${{ steps.service.outputs.repository }} - IMAGE_DIGEST: ${{ inputs.image_digest }} - SOURCE_COMMIT: ${{ inputs.source_commit }} - run: | - image_details="$(aws ecr describe-images --repository-name "$REPOSITORY" --image-ids imageDigest="$IMAGE_DIGEST" --output json)" - verified_digest="$(jq -r '.imageDetails[0].imageDigest' <<< "$image_details")" - test "$verified_digest" = "$IMAGE_DIGEST" - image_tag="sha-$SOURCE_COMMIT" - jq -e --arg image_tag "$image_tag" '.imageDetails[0].imageTags | index($image_tag) != null' <<< "$image_details" > /dev/null + uses: ./.github/actions/verify-ecr-image + with: + repository: ${{ steps.service.outputs.repository }} + image_digest: ${{ inputs.image_digest }} + source_commit: ${{ inputs.source_commit }} - name: Set up Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index e08b510..d35a68a 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -73,22 +73,9 @@ jobs: - name: Resolve selected Terraform root id: target - env: - TERRAFORM_TARGET: ${{ inputs.target }} - run: | - case "$TERRAFORM_TARGET" in - network) working_directory=infra/terraform/foundations/network ;; - security-identity) working_directory=infra/terraform/foundations/security-identity ;; - platform) working_directory=infra/terraform/foundations/platform ;; - edge) working_directory=infra/terraform/foundations/edge ;; - data) working_directory=infra/terraform/stacks/data ;; - backend-service) working_directory=infra/terraform/stacks/backend-service ;; - frontend-service) working_directory=infra/terraform/stacks/frontend-service ;; - observability) working_directory=infra/terraform/stacks/observability ;; - *) echo "Unsupported Terraform target: $TERRAFORM_TARGET"; exit 1 ;; - esac - test -d "$working_directory" - echo "working_directory=$working_directory" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-terraform-root + with: + target: ${{ inputs.target }} - name: Set up Terraform uses: hashicorp/setup-terraform@v3 diff --git a/.github/workflows/terraform-destroy.yml b/.github/workflows/terraform-destroy.yml index 5bd617e..15fa9e9 100644 --- a/.github/workflows/terraform-destroy.yml +++ b/.github/workflows/terraform-destroy.yml @@ -73,22 +73,9 @@ jobs: - name: Resolve selected Terraform root id: target - env: - TERRAFORM_TARGET: ${{ inputs.target }} - run: | - case "$TERRAFORM_TARGET" in - network) working_directory=infra/terraform/foundations/network ;; - security-identity) working_directory=infra/terraform/foundations/security-identity ;; - platform) working_directory=infra/terraform/foundations/platform ;; - edge) working_directory=infra/terraform/foundations/edge ;; - data) working_directory=infra/terraform/stacks/data ;; - backend-service) working_directory=infra/terraform/stacks/backend-service ;; - frontend-service) working_directory=infra/terraform/stacks/frontend-service ;; - observability) working_directory=infra/terraform/stacks/observability ;; - *) echo "Unsupported Terraform target: $TERRAFORM_TARGET"; exit 1 ;; - esac - test -d "$working_directory" - echo "working_directory=$working_directory" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-terraform-root + with: + target: ${{ inputs.target }} - name: Set up Terraform uses: hashicorp/setup-terraform@v3 diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index f3f2323..644189a 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -62,22 +62,9 @@ jobs: - name: Resolve selected Terraform root id: target - env: - TERRAFORM_TARGET: ${{ inputs.target }} - run: | - case "$TERRAFORM_TARGET" in - network) working_directory=infra/terraform/foundations/network ;; - security-identity) working_directory=infra/terraform/foundations/security-identity ;; - platform) working_directory=infra/terraform/foundations/platform ;; - edge) working_directory=infra/terraform/foundations/edge ;; - data) working_directory=infra/terraform/stacks/data ;; - backend-service) working_directory=infra/terraform/stacks/backend-service ;; - frontend-service) working_directory=infra/terraform/stacks/frontend-service ;; - observability) working_directory=infra/terraform/stacks/observability ;; - *) echo "Unsupported Terraform target: $TERRAFORM_TARGET"; exit 1 ;; - esac - test -d "$working_directory" - echo "working_directory=$working_directory" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/resolve-terraform-root + with: + target: ${{ inputs.target }} - name: Set up Terraform uses: hashicorp/setup-terraform@v3