Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/actions/deploy-ecs-service/action.yml
Original file line number Diff line number Diff line change
@@ -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"
56 changes: 56 additions & 0 deletions .github/actions/publish-ecr-image/action.yml
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions .github/actions/resolve-ecs-service/action.yml
Original file line number Diff line number Diff line change
@@ -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"
51 changes: 51 additions & 0 deletions .github/actions/resolve-service-bootstrap-target/action.yml
Original file line number Diff line number Diff line change
@@ -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"
39 changes: 39 additions & 0 deletions .github/actions/resolve-terraform-root/action.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading