Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(action): add ecs deployment workflow #1

Merged
merged 1 commit into from
Feb 15, 2024
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: CI

on: [ push ]

jobs:
lint-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- uses: moneymeets/action-setup-python-poetry@master

- uses: moneymeets/moneymeets-composite-actions/lint-python@master

- run: poetry run python -m pytest --cov --cov-fail-under=95
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.idea/*
!/.idea/watcherTasks.xml
__pycache__/
.coverage
.tmp/
115 changes: 115 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: "AWS ECS Task Deploy"
description: "Deploy production image to AWS ECS"
inputs:
environment:
description: Deployment environment
required: true
ecr_repository:
description: ECR repository to pull image from
required: true

aws_access_key_id:
description: AWS access key
required: true
aws_secret_access_key:
description: AWS secret access key
required: true
aws_region:
description: AWS region
required: true

runs:
using: "composite"
steps:
- uses: actions/checkout@v4

- id: configure-aws-credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ inputs.aws_access_key_id }}
aws-secret-access-key: ${{ inputs.aws_secret_access_key }}
aws-region: ${{ inputs.aws_region }}

- id: login-ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Setup Python + Poetry
uses: moneymeets/action-setup-python-poetry@master
with:
working_directory: ${{ github.action_path }}
# ToDo: Re-enable cache when https://github.com/actions/setup-python/issues/361 is fixed
poetry_cache_enabled: 'false'

- id: get-image-uri
uses: moneymeets/action-ecs-deploy/custom-deploy-steps/get-image-uri@master
with:
ecr_repository: ${{ inputs.ecr_repository }}
aws_region: ${{ inputs.aws_region }}

- name: Render and deploy local-exec task definition to Amazon ECS
id: deploy-local-task-definition
uses: moneymeets/action-ecs-deploy/custom-deploy-steps/create-task-definition@master
with:
application_id: ${{ inputs.ecr_repository }}-local-exec-${{ inputs.environment }}
image_uri: ${{ steps.get-image-uri.outputs.image-uri }}
aws_access_key_id: ${{ inputs.aws_access_key_id }}
aws_secret_access_key: ${{ inputs.aws_secret_access_key }}
aws_region: ${{ inputs.aws_region }}

- name: Render and deploy task definition to Amazon ECS
id: deploy-task-definition
uses: moneymeets/action-ecs-deploy/custom-deploy-steps/create-task-definition@master
with:
application_id: ${{ inputs.ecr_repository }}-${{ inputs.environment }}
image_uri: ${{ steps.get-image-uri.outputs.image-uri }}
aws_access_key_id: ${{ inputs.aws_access_key_id }}
aws_secret_access_key: ${{ inputs.aws_secret_access_key }}
aws_region: ${{ inputs.aws_region }}

# Service is managed by Pulumi, only desired count and task definition should be updated here
- name: Update service
shell: bash
run: |
aws ecs update-service \
--health-check-grace-period-seconds 900 \
--task-definition "${{ steps.deploy-task-definition.outputs.latest-task-definition-arn }}" \
--cluster ${{ inputs.environment }} \
--service ${{ inputs.ecr_repository }}-${{ inputs.environment }} \
--desired-count 1 \
--region ${{ inputs.aws_region }}

- name: Await service stability
shell: bash
# ToDo: MD-7199 Re-evaluate logic after deployment failures are correctly handled by ECS
id: check-service-stability
run: |
aws ecs wait services-stable \
--cluster ${{ inputs.environment }} \
--service ${{ inputs.ecr_repository }}-${{ inputs.environment }} \
--region ${{ inputs.aws_region }}

- name: Deregister previous local-exec task definition
if: ${{ always() && steps.deploy-local-task-definition.outputs.previous-task-definition-arn != '' }}
shell: bash
run: |
if [ "${{ steps.check-service-stability.outcome }}" == 'success' ]; then
TASK_DEFINITION_TO_DEREGISTER="${{ steps.deploy-local-task-definition.outputs.previous-task-definition-arn }}"
else
TASK_DEFINITION_TO_DEREGISTER="${{ steps.deploy-local-task-definition.outputs.latest-task-definition-arn }}"
fi
aws ecs deregister-task-definition \
--task-definition "$TASK_DEFINITION_TO_DEREGISTER" \
--region ${{ inputs.aws_region }}

- name: Deregister previous task definition
if: ${{ always() && steps.deploy-task-definition.outputs.previous-task-definition-arn != '' }}
shell: bash
run: |
if [ "${{ steps.check-service-stability.outcome }}" == 'success' ]; then
TASK_DEFINITION_TO_DEREGISTER="${{ steps.deploy-task-definition.outputs.previous-task-definition-arn }}"
else
TASK_DEFINITION_TO_DEREGISTER="${{ steps.deploy-task-definition.outputs.latest-task-definition-arn }}"
fi
aws ecs deregister-task-definition \
--task-definition "$TASK_DEFINITION_TO_DEREGISTER" \
--region ${{ inputs.aws_region }}
Empty file added actions_helper/__init__.py
Empty file.
111 changes: 111 additions & 0 deletions actions_helper/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import os
from dataclasses import dataclass

import boto3
import click
from botocore.client import BaseClient


class NonSingleValueError(Exception):
pass


@dataclass
class Tag:
key: str
value: str


def format_tags(task_definition_tags: str) -> tuple[Tag, ...]:
try:
return tuple(
Tag(tag.strip().split(":")[0], tag.strip().split(":")[1]) for tag in task_definition_tags.split(",")
)
except IndexError:
raise ValueError(
"Invalid task definition tag format, expects 'key:value,key:value'",
)


def get_active_task_definition_arn_by_tag(
*,
ecs_client: BaseClient,
task_definition_family_prefix: str,
task_definition_tags: str,
allow_initial_deployment: bool,
) -> str:
tags = format_tags(task_definition_tags)

active_task_definition_arns = ecs_client.list_task_definitions(
familyPrefix=task_definition_family_prefix,
status="ACTIVE",
sort="DESC",
)["taskDefinitionArns"]

tagged_active_task_definitions = tuple(
task_definition["taskDefinition"]["taskDefinitionArn"]
for task_definition_arn in active_task_definition_arns
if (
task_definition := ecs_client.describe_task_definition(
taskDefinition=task_definition_arn,
include=["TAGS"],
)
)
and all({"key": tag.key, "value": tag.value} in task_definition["tags"] for tag in tags)
)

# Allows for initial deployment of task definition.
# Requires that the only other active task definition is to be created by Pulumi,
# in order to prevent multiple deployed task definitions with different tags.
if (
allow_initial_deployment
and len(active_task_definition_arns) == 1
and {"key": "created_by", "value": "Pulumi"}
in ecs_client.describe_task_definition(
taskDefinition=active_task_definition_arns[0],
include=["TAGS"],
)["tags"]
):
return ""

try:
(task_definition,) = tagged_active_task_definitions
return task_definition
except ValueError as e:
raise NonSingleValueError(
f"Expected exactly one active task definition with tags {task_definition_tags}. "
f"Found: {tagged_active_task_definitions}",
) from e


@click.group()
def cli():
pass


@cli.command(
name="get-active-task-definition-arn-by-tag",
short_help="Get active task definition ARN by specified tags",
)
@click.option("--application-id", default=os.environ.get("APPLICATION_ID"), type=str)
@click.option("--tags", default=os.environ.get("TAGS"), type=str)
@click.option("--aws-region", default=os.environ.get("AWS_DEFAULT_REGION"), type=str)
@click.option("--allow-initial-deployment", is_flag=True, default=False)
def cmd_get_active_task_definition_arn_by_tag(
application_id: str,
tags: str,
aws_region: str,
allow_initial_deployment: bool,
):
click.echo(
get_active_task_definition_arn_by_tag(
ecs_client=boto3.Session(region_name=aws_region).client("ecs"),
task_definition_family_prefix=application_id,
task_definition_tags=tags,
allow_initial_deployment=allow_initial_deployment,
),
)


if __name__ == "__main__":
cli()
97 changes: 97 additions & 0 deletions custom-deploy-steps/create-task-definition/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Create task definition revision
description: Use image_uri to create new task definition revision, and deregister previous active task definition

inputs:
application_id:
description: Application ID also the task definition name in this case
required: true
image_uri:
description: The URI of the container image to insert into the ECS task definition
required: true

aws_access_key_id:
description: AWS access key
required: true
aws_secret_access_key:
description: AWS secret access key
required: true
aws_region:
description: AWS region
required: true

outputs:
previous-task-definition-arn:
description: "ARN of previous task definition revision"
value: ${{ steps.get-github-action-task-definition-arn.outputs.previous-task-definition-arn }}
latest-task-definition-arn:
description: "ARN of new task definition revision"
value: ${{ steps.deploy-task-definition.outputs.task-definition-arn }}

runs:
using: composite
steps:
- uses: actions/checkout@v4

- name: Get latest active task definition revision created by Pulumi
id: get-pulumi-task-definition-arn
shell: bash
env:
APPLICATION_ID: ${{ inputs.application_id }}
AWS_REGION: ${{ inputs.aws_region }}
run: |
TASK_DEFINITION_ARN=$(
poetry run --directory ${{ github.action_path }} actions_helper get-active-task-definition-arn-by-tag \
--application-id "$APPLICATION_ID" \
--tags "created_by:Pulumi,Name:$APPLICATION_ID" \
--aws-region "$AWS_REGION"
)
echo "task-definition-arn=${TASK_DEFINITION_ARN}" >> $GITHUB_OUTPUT

- name: Download and check task definition
shell: bash
run: |
aws ecs describe-task-definition \
--task-definition ${{ steps.get-pulumi-task-definition-arn.outputs.task-definition-arn }} \
--query taskDefinition \
--region ${{ inputs.aws_region }} \
> task-definition.json

result=$(jq '.' task-definition.json | jq '.containerDefinitions[].image | select(. != "PLACEHOLDER")')
if [ "$result" ]; then
echo "Error: Not all values for containerDefinitions 'image' equal to 'PLACEHOLDER'"
exit 1
fi

- name: Replace PLACEHOLDER in task definition with image URI
shell: bash
run: |
jq --arg image_uri "${{ inputs.image_uri }}" '.containerDefinitions[].image |= $image_uri' \
task-definition.json > tmpfile && mv tmpfile task-definition.json

- name: Get latest active task definition created by GitHub Actions deployment
id: get-github-action-task-definition-arn
shell: bash
env:
APPLICATION_ID: ${{ inputs.application_id }}
run: |
TASK_DEFINITION_ARN=$(
poetry run --directory ${{ github.action_path }} actions_helper get-active-task-definition-arn-by-tag \
--application-id "$APPLICATION_ID" \
--tags "created_by:GitHub Actions Deployment,Name:$APPLICATION_ID" \
--aws-region "$AWS_REGION" \
--allow-initial-deployment
)
echo "previous-task-definition-arn=${TASK_DEFINITION_ARN}" >> $GITHUB_OUTPUT

- uses: aws-actions/amazon-ecs-deploy-task-definition@v1
id: deploy-task-definition
with:
task-definition: task-definition.json

- name: Add tags to the new task definition revision
shell: bash
run: |
aws ecs tag-resource \
--resource-arn ${{ steps.deploy-task-definition.outputs.task-definition-arn }} \
--tags key=created_by,value='GitHub Actions Deployment' key=Name,value=${{ inputs.application_id }} \
--region ${{ inputs.aws_region }}
43 changes: 43 additions & 0 deletions custom-deploy-steps/get-image-uri/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Get image URI
description: Get image uri from AWS ECR

inputs:
ecr_repository:
description: ECR repository to pull image from
required: true
aws_region:
description: AWS region
required: true

outputs:
image-uri:
description: "Image URI"
value: ${{ steps.get-image-uri.outputs.image-uri }}

runs:
using: composite
steps:
- uses: actions/checkout@v4

- name: Get Image URI
shell: bash
id: get-image-uri
run: |
imageTag=master-${{ github.sha }}
IMAGE_TAGS=$(
aws ecr describe-images \
--repository-name ${{ inputs.ecr_repository }} \
--image-ids imageTag="${imageTag}" \
--query 'imageDetails[*].imageTags[0]' \
--output json \
--region ${{ inputs.aws_region }}
)
REPOSITORY_URI=$(
aws ecr describe-repositories \
--repository-name ${{ inputs.ecr_repository }} \
--query 'repositories[0].repositoryUri' \
--output text \
--region ${{ inputs.aws_region }}
)
IMAGE_URI=$(jq --arg uri "${REPOSITORY_URI}" '.[] | ($uri + ":" + .)' <<< "${IMAGE_TAGS}")
echo "image-uri=${IMAGE_URI}" >> $GITHUB_OUTPUT
Loading
Loading