diff --git a/.github/scripts/argocd_deploy.py b/.github/scripts/argocd_deploy.py new file mode 100644 index 00000000..55333ec1 --- /dev/null +++ b/.github/scripts/argocd_deploy.py @@ -0,0 +1,132 @@ +import logging +import typing as t +from argparse import ArgumentParser, Namespace +from json import loads +from logging import Formatter, StreamHandler +from math import floor +from os import environ, getenv +from subprocess import run as subproccess_run +from sys import exit, stdout + + +# Wrapper for ArgoCD CLI +# https://argo-cd.readthedocs.io/en/stable/user-guide/commands/argocd/ +class ArgoCDeployer: + def __init__(self, application: str, docker_tag: str) -> None: + self.application = application or getenv('ARGO_CD_APPLICATION', environ['DEPLOYMENT']) + self.docker_tag = docker_tag or environ['DOCKER_TAG'] + self.image_tag_parameter = getenv('IMAGE_TAG_PARAMETER', 'global.image.tag=') + self.argo_cd_credentials = [ + f'--username={environ["ARGO_CD_USERNAME"]}', + f'--password={environ["ARGO_CD_PASSWORD"]}', + ] + + def _execute_cli(self, command: t.List[str], return_stdout: bool = False) -> t.Optional[str]: + command = ['argocd'] + command + LOG.info(' '.join(command).replace(environ['ARGO_CD_PASSWORD'], 'redacted')) + output = subproccess_run(command, capture_output=True, encoding='utf-8') + if output.returncode != 0: + LOG.fatal(f'ArgoCD CLI failed {command=} returncode={output.returncode} standard_error={output.stderr}') + if return_stdout: + return output.stdout + + def login(self): + self._execute_cli( + command=[ + 'login', + '--grpc-web', + environ['ARGO_CD_SERVER'], + *self.argo_cd_credentials, + ] + ) + + def update_image_tag(self): + args = [] + parameters = [ + f'{self.image_tag_parameter}{self.docker_tag}', + ] + for parameter in parameters: + args.extend(['--parameter', parameter]) + self._execute_cli(command=['app', 'set', self.application, *args]) + + def sync_app(self, preview: bool = False, timeout: int = 1200): + args = [ + '--assumeYes', + '--prune', + '--retry-backoff-max-duration', + f'{floor(timeout/60)}m', + '--timeout', + str(timeout + 30), + ] + if preview: + args.append('--dry-run') + self._execute_cli(command=['app', 'sync', self.application, *args]) + + def preview_deploy(self): + self.sync_app(preview=True) + + def wait_health(self): + args = [ + '--health', + '--sync', + '--timeout', + '600', + ] + self._execute_cli(command=['app', 'wait', self.application, *args]) + + def verify_health(self): + args = [ + '--output', + 'json', + ] + info = self._execute_cli(command=['app', 'get', self.application, *args], return_stdout=True) + info = loads(info) + status = info['status'] + if status['health']['status'] == 'Degraded': + LOG.critical( + f'Application is unhealthy, aborting deploy\nCheck Application https://ARGO_CD_SERVER/applications/argocd/{self.application}?view=network&resource=' + ) + exit(1) + sync_status = status['sync']['status'] + if sync_status not in ['Synced', 'OutofSync']: + LOG.info('Application non-deployable SyncStatus={sync_status} - Will try to wait before aborting') + self.wait_health() + + +def Logger(logger_name): + logger = logging.getLogger(logger_name) + + logger.setLevel(logging.INFO) + + standard_output = StreamHandler(stdout) + standard_output.setFormatter( + Formatter( + '{asctime} {name} {levelname:8} {message}', + '%Y-%m-%d %H:%M:%S', + '{', + ) + ) + logger.addHandler(standard_output) + return logger + + +if __name__ == '__main__': + LOG = Logger('argocd.deploy') + + parser = ArgumentParser( + prog='ArgoCDeployer', + description='Deploy ArgoCD Applications utilizing argocd CLI', + ) + # TODO: Convert arguments to non-positional args and enforce required=True + # Requires CICD changes where command is utilized + parser.add_argument('--application', help='ArgoCD Application Name i.e., foobar-dev') + parser.add_argument('--docker_tag', help='Docker Tag to be deployed, usually the full Git SHA') + args: Namespace = parser.parse_args() + + argocd = ArgoCDeployer(application=args.application, docker_tag=args.docker_tag) + argocd.login() + argocd.verify_health() + argocd.update_image_tag() + argocd.sync_app() + + LOG.info(f'Deploy succeeded {args.application} docker_tag={args.docker_tag}') diff --git a/.github/scripts/requirements.in b/.github/scripts/requirements.in new file mode 100644 index 00000000..d95dda96 --- /dev/null +++ b/.github/scripts/requirements.in @@ -0,0 +1,3 @@ +boto3 +boto3-stubs[ecr] +botocore diff --git a/.github/scripts/requirements.txt b/.github/scripts/requirements.txt new file mode 100644 index 00000000..cf9155af --- /dev/null +++ b/.github/scripts/requirements.txt @@ -0,0 +1,39 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile requirements.in +# +boto3==1.34.7 + # via -r requirements.in +boto3-stubs[ecr]==1.34.7 + # via -r requirements.in +botocore==1.34.7 + # via + # -r requirements.in + # boto3 + # s3transfer +botocore-stubs==1.34.7 + # via boto3-stubs +jmespath==1.0.1 + # via + # boto3 + # botocore +mypy-boto3-ecr==1.34.0 + # via boto3-stubs +python-dateutil==2.8.2 + # via botocore +s3transfer==0.10.0 + # via boto3 +six==1.16.0 + # via python-dateutil +types-awscrt==0.20.0 + # via botocore-stubs +types-s3transfer==0.10.0 + # via boto3-stubs +typing-extensions==4.9.0 + # via + # boto3-stubs + # mypy-boto3-ecr +urllib3==2.0.7 + # via botocore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2f4892e3..f9fc2966 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,6 @@ --- name: ci -run-name: ci ${{ github.event_name}} ${{ github.ref_name }} +run-name: ci ${{ github.event_name }} ${{ github.ref_name }} on: pull_request: push: diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 5f670873..12cc967d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,15 +1,16 @@ --- name: deploy -run-name: ${{ github.event.inputs.env }} deploy ${{ github.ref_name }} ${{ github.event_name }} by ${{ github.actor }} +run-name: ${{ github.event.inputs.env }} deploy ${{ github.ref_name }} on: workflow_dispatch: inputs: env: type: environment + description: 'ArgoCD Deployed environment' ref: description: 'Branch, Tag, or Full SHA' required: true - default: 'develop' + default: 'main' concurrency: group: ${{ github.event.inputs.env }} @@ -26,15 +27,25 @@ jobs: env: ENVIRONMENT: ${{ github.event.inputs.env }} steps: - - uses: actions/checkout@v3 - - uses: aws-actions/configure-aws-credentials@v3 + - uses: actions/checkout@v4 with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions - role-session-name: gha - aws-region: us-west-2 - - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 + ref: ${{ github.event.inputs.ref }} + - uses: clowdhaus/argo-cd-action/@main with: - mask: 'true' - - run: make deploy - - run: make deploy-status + version: 2.10.0 + command: version + options: --client + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + - run: pip install -r .github/scripts/requirements.txt + - name: deploy + run: |- + python .github/scripts/argocd_deploy.py \ + --application=happeningatm-${{ env.ENVIRONMENT }} \ + --docker_tag=$(git rev-parse HEAD) + env: + ARGO_CD_SERVER: ${{ secrets.ARGO_CD_SERVER }} + ARGO_CD_USERNAME: ${{ secrets.ARGO_CD_USERNAME }} + ARGO_CD_PASSWORD: ${{ secrets.ARGO_CD_PASSWORD }}