From 283e96efc447cde87d7a0785ccff2d42311b7088 Mon Sep 17 00:00:00 2001 From: object-Object Date: Tue, 2 Jan 2024 17:41:25 -0500 Subject: [PATCH] Add initial CodeDeploy setup --- .github/workflows/deploy.yml | 143 ++++++++++++++++++++ codedeploy/appspec.yml | 19 +++ codedeploy/pm2.config.js | 8 ++ codedeploy/scripts/aws/after-install.sh | 8 ++ codedeploy/scripts/aws/application-start.sh | 11 ++ codedeploy/scripts/aws/application-stop.sh | 11 ++ codedeploy/scripts/aws/validate-service.sh | 21 +++ codedeploy/scripts/pm2/run.sh | 5 + pyproject.toml | 9 +- src/infra/__init__.py | 0 src/infra/app.py | 64 +++++++++ src/infra/stack.py | 110 +++++++++++++++ 12 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 codedeploy/appspec.yml create mode 100644 codedeploy/pm2.config.js create mode 100644 codedeploy/scripts/aws/after-install.sh create mode 100644 codedeploy/scripts/aws/application-start.sh create mode 100644 codedeploy/scripts/aws/application-stop.sh create mode 100644 codedeploy/scripts/aws/validate-service.sh create mode 100644 codedeploy/scripts/pm2/run.sh create mode 100644 src/infra/__init__.py create mode 100644 src/infra/app.py create mode 100644 src/infra/stack.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..ae0e95b --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,143 @@ +name: Build and deploy + +on: + push: + branches: + - "main" + workflow_dispatch: + +env: + AWS_REGION: us-east-1 + STACK_NAME: prod-HexBug + S3_BUCKET: prod-objectobject-ca-codedeploy-artifacts + CDK_IAM_ROLE_ARN: arn:aws:iam::511603859520:role/prod-objectobject-ca-GitHubActionsCDKRole19D97701-sweSB0Sp33WN + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + cache: pip + + - name: Install Python packages + run: pip install . hatch + + # we use a GitHub deployment for this, so there's no artifact upload + # the build is just to see if it breaks or not + - name: Build wheel + run: hatch build --target wheel + + deploy-aws-cdk: + needs: build + runs-on: ubuntu-latest + environment: + name: prod-aws-cdk + permissions: + id-token: write + contents: read + outputs: + application-name: ${{ steps.cdk-outputs.outputs.application-name }} + deployment-group-name: ${{ steps.cdk-outputs.outputs.deployment-group-name }} + iam-role-arn: ${{ steps.cdk-outputs.outputs.iam-role-arn }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v3 + with: + node-version: 18 + + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + cache: "pip" + + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ env.AWS_REGION }} + role-to-assume: ${{ env.CDK_IAM_ROLE_ARN }} + + - name: Install CDK CLI + run: npm install -g aws-cdk + + - name: Install Python packages + run: pip install .[aws-cdk] + + - name: Deploy CDK stack + run: cdk deploy prod --ci --require-approval never --outputs-file outputs.json + + - name: Parse CDK outputs file + id: cdk-outputs + run: | + outputs_json="$(cat outputs.json | jq '.["${{ env.STACK_NAME }}"]')" + echo "application-name=$(echo "$outputs_json" | jq '.ApplicationName' --raw-output)" >> "$GITHUB_OUTPUT" + echo "deployment-group-name=$(echo "$outputs_json" | jq '.DeploymentGroupName' --raw-output)" >> "$GITHUB_OUTPUT" + echo "iam-role-arn=$(echo "$outputs_json" | jq '.GitHubActionsRoleARN' --raw-output)" >> "$GITHUB_OUTPUT" + + # deploy-codedeploy: + # needs: deploy-aws-cdk + # runs-on: ubuntu-latest + # environment: + # name: prod-codedeploy + # url: ${{ steps.create-deployment.outputs.url }} + # permissions: + # id-token: write + # contents: read + # steps: + # - uses: actions/checkout@v4 + + # - uses: aws-actions/configure-aws-credentials@v4 + # with: + # aws-region: ${{ env.AWS_REGION }} + # role-to-assume: ${{ needs.deploy-aws-cdk.outputs.iam-role-arn }} + + # - name: Download build artifact + # uses: actions/download-artifact@v3 + # with: + # name: build + # path: codedeploy/dist + + # - name: Set environment variables + # run: | + # cat < codedeploy/.env + # TOKEN="${{ secrets.DISCORD_TOKEN }}" + # LOG_WEBHOOK_URL="${{ secrets.LOG_WEBHOOK_URL }}" + # GITHUB_SHA=main + # GITHUB_REPOSITORY=object-Object/HexBug + # GITHUB_PAGES_URL=https://object-object.github.io/HexBug + # EOF + + # - name: Upload deployment bundle to S3 + # id: upload-bundle + # run: | + # S3_KEY="${{ env.STACK_NAME }}/${{ github.sha }}.zip" + # echo "s3-key=$S3_KEY" >> "$GITHUB_OUTPUT" + # aws deploy push \ + # --application-name ${{ needs.deploy-aws-cdk.outputs.application-name }} \ + # --s3-location s3://${{ env.S3_BUCKET }}/$S3_KEY \ + # --source codedeploy + + # - name: Create CodeDeploy deployment + # id: create-deployment + # run: | + # response="$(aws deploy create-deployment \ + # --application-name ${{ needs.deploy-aws-cdk.outputs.application-name }} \ + # --deployment-group-name ${{ needs.deploy-aws-cdk.outputs.deployment-group-name }} \ + # --s3-location "bucket=${{ env.S3_BUCKET }},key=${{ steps.upload-bundle.outputs.s3-key }},bundleType=zip")" + + # deployment_id="$(echo "$response" | jq '.deploymentId' --raw-output)" + # url="https://${{ env.AWS_REGION }}.console.aws.amazon.com/codesuite/codedeploy/deployments/${deployment_id}?region=${{ env.AWS_REGION }}" + # echo "Deployment URL: $url" + + # echo "deployment-id=$deployment_id" >> "$GITHUB_OUTPUT" + # echo "url=$url" >> "$GITHUB_OUTPUT" + + # - name: Wait for deployment to finish + # run: aws deploy wait deployment-successful --deployment-id ${{ steps.create-deployment.outputs.deployment-id }} diff --git a/codedeploy/appspec.yml b/codedeploy/appspec.yml new file mode 100644 index 0000000..65b8d75 --- /dev/null +++ b/codedeploy/appspec.yml @@ -0,0 +1,19 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /var/lib/codedeploy-apps/HexBug/ +file_exists_behavior: OVERWRITE +hooks: + ApplicationStop: + - location: scripts/aws/application-stop.sh + timeout: 60 + AfterInstall: + - location: scripts/aws/after-install.sh + timeout: 300 + ApplicationStart: + - location: scripts/aws/application-start.sh + timeout: 60 + ValidateService: + - location: scripts/aws/validate-service.sh + timeout: 60 diff --git a/codedeploy/pm2.config.js b/codedeploy/pm2.config.js new file mode 100644 index 0000000..4ce08c9 --- /dev/null +++ b/codedeploy/pm2.config.js @@ -0,0 +1,8 @@ +module.exports = { + apps: [{ + name: "HexBug", + script: "./scripts/pm2/run.sh", + min_uptime: "5s", + max_restarts: 5, + }] +} diff --git a/codedeploy/scripts/aws/after-install.sh b/codedeploy/scripts/aws/after-install.sh new file mode 100644 index 0000000..b415971 --- /dev/null +++ b/codedeploy/scripts/aws/after-install.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +cd /var/lib/codedeploy-apps/HexBug + +python3.11 -m venv venv #--clear +source venv/bin/activate +pip install -e ".[runtime]" diff --git a/codedeploy/scripts/aws/application-start.sh b/codedeploy/scripts/aws/application-start.sh new file mode 100644 index 0000000..05081fd --- /dev/null +++ b/codedeploy/scripts/aws/application-start.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +run_pm2() { + sudo su object -c "pm2 --no-color --mini-list $*" +} + +cd /var/lib/codedeploy-apps/HexBug + +run_pm2 start pm2.config.js +run_pm2 save diff --git a/codedeploy/scripts/aws/application-stop.sh b/codedeploy/scripts/aws/application-stop.sh new file mode 100644 index 0000000..1d8cda9 --- /dev/null +++ b/codedeploy/scripts/aws/application-stop.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +run_pm2() { + sudo su object -c "pm2 --no-color --mini-list $*" +} + +cd /var/lib/codedeploy-apps/HexBug + +run_pm2 delete pm2.config.js || true +run_pm2 save diff --git a/codedeploy/scripts/aws/validate-service.sh b/codedeploy/scripts/aws/validate-service.sh new file mode 100644 index 0000000..b739062 --- /dev/null +++ b/codedeploy/scripts/aws/validate-service.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail + +run_pm2() { + sudo su object -c "pm2 --no-color --mini-list $*" +} + +# give it time to start up +sleep 10s + +# unix timestamp in milliseconds when the process was last started +start="$(run_pm2 jlist | jq --exit-status '[.[] | if .name == "HexBug" then .pm2_env.pm_uptime else null end | values][0]')" + +# current unix timestamp in milliseconds +end="$(date +%s%3N)" + +elapsed="$(( (end - start) / 1000 ))" +if [[ $elapsed -lt 5 ]]; then + echo "Uptime too low (expected >=5 seconds, got $elapsed)." + exit 1 +fi diff --git a/codedeploy/scripts/pm2/run.sh b/codedeploy/scripts/pm2/run.sh new file mode 100644 index 0000000..6ccbdf7 --- /dev/null +++ b/codedeploy/scripts/pm2/run.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +source venv/bin/activate +python main.py diff --git a/pyproject.toml b/pyproject.toml index c7c3540..e2b46b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,11 @@ dependencies = [ ] [project.optional-dependencies] -all = [ +aws-cdk = [ + "aws-cdk-lib==2.102.0", + "aws-cdk-github-oidc==2.4.0", +] +runtime = [ "networkx~=3.1", "matplotlib~=3.6", "lark~=1.1", @@ -35,6 +39,9 @@ all = [ "semver~=3.0", "hexnumgen @ git+https://github.com/object-Object/hexnumgen-rs.git@70d683ee9b", ] +dev = [ + "HexBug[runtime,aws-cdk]" +] [project.urls] Source = "https://github.com/object-Object/HexBug" diff --git a/src/infra/__init__.py b/src/infra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infra/app.py b/src/infra/app.py new file mode 100644 index 0000000..0e5b60a --- /dev/null +++ b/src/infra/app.py @@ -0,0 +1,64 @@ +import logging +from typing import TypedDict + +import aws_cdk as cdk + +from .stack import CDKStack + +logger = logging.getLogger(__name__) + + +class CommonKwargs(TypedDict): + oidc_owner: str + oidc_repo: str + + +def main(): + setup_logging() + + logger.info("Ready.") + app = cdk.App() + + common = CommonKwargs( + oidc_owner="object-Object", + oidc_repo="HexBug", + ) + + CDKStack( + app, + stage="prod", + env=cdk.Environment( + account="511603859520", + region="us-east-1", + ), + artifacts_bucket_name="prod-objectobject-ca-codedeploy-artifacts", + on_premise_instance_tag="prod-objectobject-ca", + oidc_environment="prod-codedeploy", + **common, + ) + + logger.info("Synthesizing.") + app.synth() + + print() + + +def setup_logging(verbose: bool = False): + if verbose: + level = logging.DEBUG + fmt = "[{asctime} | {name} | {levelname}] {message}" + else: + level = logging.INFO + fmt = "[{levelname}] {message}" + + logging.basicConfig( + style="{", + datefmt="%Y-%m-%d %H:%M:%S", + format=fmt, + level=level, + ) + logging.getLogger(__name__).debug("Logger initialized.") + + +if __name__ == "__main__": + main() diff --git a/src/infra/stack.py b/src/infra/stack.py new file mode 100644 index 0000000..439c97b --- /dev/null +++ b/src/infra/stack.py @@ -0,0 +1,110 @@ +import logging + +import aws_cdk as cdk +from aws_cdk import aws_codedeploy as codedeploy +from aws_cdk import aws_iam as iam +from aws_cdk import aws_s3 as s3 +from aws_cdk_github_oidc import GithubActionsIdentityProvider, GithubActionsRole +from constructs import Construct + +BASE_STACK_NAME = "HexBug" + + +class CDKStack(cdk.Stack): + def __init__( + self, + scope: Construct, + *, + stage: str, + env: cdk.Environment, + artifacts_bucket_name: str, + on_premise_instance_tag: str, + oidc_owner: str, + oidc_repo: str, + oidc_environment: str, + ): + stack_name = f"{stage}-{BASE_STACK_NAME}" + + logging.getLogger(__name__).info(f"Initializing stack: {stack_name}") + super().__init__( + scope, + stage, + stack_name=stack_name, + env=env, + ) + + # external resources + + oidc_proxy = GithubActionsIdentityProvider.from_account(self, "OIDCProxy") + + artifacts_bucket_proxy = s3.Bucket.from_bucket_name( + self, + "ArtifactsBucketProxy", + artifacts_bucket_name, + ) + + # codedeploy application + + application = codedeploy.ServerApplication( + self, + f"{stack_name}-Application", + ) + + deployment_config: codedeploy.ServerDeploymentConfig = ( + codedeploy.ServerDeploymentConfig.ONE_AT_A_TIME + ) + + deployment_group = codedeploy.ServerDeploymentGroup( + self, + "DeploymentGroup", + application=application, + deployment_config=deployment_config, + auto_rollback=codedeploy.AutoRollbackConfig( + failed_deployment=True, + ), + on_premise_instance_tags=codedeploy.InstanceTagSet( + {"instance": [on_premise_instance_tag]} + ), + ) + + # GitHub Actions + + github_actions_role = GithubActionsRole( + self, + "GitHubActionsRole", + provider=oidc_proxy, + owner=oidc_owner, + repo=oidc_repo, + filter=f"environment:{oidc_environment}", + ) + artifacts_bucket_proxy.grant_read_write(github_actions_role) + github_actions_role.add_to_policy( + iam.PolicyStatement( + actions=["codedeploy:*"], + resources=[ + application.application_arn, + deployment_group.deployment_group_arn, + deployment_config.deployment_config_arn, + ], + ) + ) + + # outputs + + cdk.CfnOutput( + self, + "ApplicationName", + value=application.application_name, + ) + + cdk.CfnOutput( + self, + "DeploymentGroupName", + value=deployment_group.deployment_group_name, + ) + + cdk.CfnOutput( + self, + "GitHubActionsRoleARN", + value=github_actions_role.role_arn, + )