diff --git a/.github/workflows/PR-validation.yml b/.github/workflows/PR-validation.yml index 140ba518..f84adc5d 100644 --- a/.github/workflows/PR-validation.yml +++ b/.github/workflows/PR-validation.yml @@ -425,4 +425,4 @@ jobs: role-session-name: gh-actions-${{github.run_id}}.${{github.run_number}}.${{github.run_attempt}}-cdk-test aws-region: us-east-1 - name: Validate production CDK stack code - run: make validate + run: make validate-all diff --git a/.github/workflows/main-build-and-deploy.yml b/.github/workflows/main-build-and-deploy.yml index a70f2b59..255abdb5 100644 --- a/.github/workflows/main-build-and-deploy.yml +++ b/.github/workflows/main-build-and-deploy.yml @@ -79,7 +79,7 @@ jobs: role-session-name: gh-actions-${{github.run_id}}.${{github.run_number}}.${{github.run_attempt}}-cdk-deploy aws-region: us-east-1 - name: CDK validations (resource assertions and cdk diff) - run: make validate + run: make validate-image-stack - name: cdk deploy run: cdk deploy PaviApiImageRepoCdkStack --require-approval never pipeline-seq-retrieval-build-and-push-docker-image: @@ -189,3 +189,50 @@ jobs: ${{ steps.login-ecr.outputs.registry }}/agr_pavi/api:${{ env.tagname }} ${{ steps.login-ecr.outputs.registry }}/agr_pavi/api:${{ github.event.pull_request.base.ref }} platforms: linux/amd64 + api-deploy-application: + name: Deploy application (version) for API + needs: + - on-merge-and-deploy + - api-build-and-push-docker-image + - pipeline-alignment-build-and-push-docker-image + - pipeline-seq-retrieval-build-and-push-docker-image + permissions: + id-token: write # This is required for requesting the JWT for gaining permissions to assume the IAM role to perform AWS actions + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: api/aws_infra + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Store release tag in env + shell: bash + run: | + echo "tagname=$(git describe --tags)" >> $GITHUB_ENV + - name: Setup node.js (CDK requirement) + uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Install CDK + run: npm install -g aws-cdk + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install CDK stack dependencies + run: pip install -r requirements.txt + - name: AWS credentials configuration + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{secrets.GH_ACTIONS_AWS_ROLE}} + role-session-name: gh-actions-${{github.run_id}}.${{github.run_number}}.${{github.run_attempt}}-api-cdk-deploy + aws-region: us-east-1 + - name: CDK validations (resource assertions and cdk diff) + run: make validate-application-stack validate-environment-stack + - name: Deploy application (and version) + run: make deploy-application PAVI_DEPLOY_VERSION_LABEL="${{ env.tagname }}" ADD_CDK_ARGS="--require-approval never" + - name: Deploy to main environment + run: make deploy-environment PAVI_DEPLOY_VERSION_LABEL="${{ env.tagname }}" PAVI_IMAGE_TAG="${{ env.tagname }}" \ + DEPLOY_ENVIRONMENT=PaviApiEbMainStack ADD_CDK_ARGS="--require-approval never" diff --git a/api/Dockerfile.dockerignore b/api/Dockerfile.dockerignore index 57113501..b7db222c 100644 --- a/api/Dockerfile.dockerignore +++ b/api/Dockerfile.dockerignore @@ -7,3 +7,5 @@ api/src/seq_regions*.json api/src/.nextflow* api/src/pipeline-results*/ api/src/work/ +# AWS infra code and config files +api/aws_infra/ diff --git a/api/Makefile b/api/Makefile index 97a7a0d6..25c602ec 100644 --- a/api/Makefile +++ b/api/Makefile @@ -18,7 +18,7 @@ container-image: run-container-dev: export API_PIPELINE_IMAGE_TAG=main && \ - docker-compose --env-file dev.env up agr.pavi.dev.api + docker-compose -f docker-compose-dev.yml --env-file dev.env up agr.pavi.dev-local.api nextflow.sh: make -f ../pipeline/workflow/Makefile nextflow.sh @@ -64,9 +64,9 @@ run-tests-dev: python-dependencies python-test-dependencies nextflow.sh protein- run-integration-test-container: python-dependencies python-test-dependencies export API_PIPELINE_IMAGE_TAG=${TAG_NAME} && \ - docker-compose --env-file dev.env up -d agr.pavi.dev.api + docker-compose -f docker-compose-dev.yml --env-file dev.env up -d agr.pavi.dev-local.api sleep 30 # Allow container some startup time before attempting to connect export EXTERNAL_API_BASE_URL="http://localhost:8080" && \ python -m pytest tests/integration \ - || (echo "Container logs:" && docker logs agr.pavi.dev.api.server && exit 1) - docker-compose down + || (echo "Container logs:" && docker logs agr.pavi.dev-local.api.server && exit 1) + docker-compose -f docker-compose-dev.yml down diff --git a/api/aws_infra/.ebextensions/autoscaling.yml.config b/api/aws_infra/.ebextensions/autoscaling.yml.config new file mode 100644 index 00000000..fa6f8bff --- /dev/null +++ b/api/aws_infra/.ebextensions/autoscaling.yml.config @@ -0,0 +1,7 @@ +option_settings: + aws:ec2:instances: + InstanceTypes: t2.micro + aws:autoscaling:asg: + Availability Zones: Any + MinSize: 1 + MaxSize: 1 diff --git a/api/aws_infra/.ebextensions/aws-ec2-vpc.yml.config b/api/aws_infra/.ebextensions/aws-ec2-vpc.yml.config new file mode 100644 index 00000000..573646e1 --- /dev/null +++ b/api/aws_infra/.ebextensions/aws-ec2-vpc.yml.config @@ -0,0 +1,17 @@ +option_settings: + aws:ec2:vpc: + AssociatePublicIpAddress: false + Subnets: + - subnet-0d4703177afb1797d + - subnet-04262fc338f638054 + - subnet-044457c061edf85f2 + - subnet-04019d42d5c9e6fb9 + - subnet-049778993fb504a7c + ELBSubnets: + - subnet-0d4703177afb1797d + - subnet-04262fc338f638054 + - subnet-044457c061edf85f2 + - subnet-04019d42d5c9e6fb9 + - subnet-049778993fb504a7c + VPCId: vpc-55522232 + ELBScheme: internal diff --git a/api/aws_infra/.ebextensions/cloudwatch.yml.config b/api/aws_infra/.ebextensions/cloudwatch.yml.config new file mode 100644 index 00000000..636e160b --- /dev/null +++ b/api/aws_infra/.ebextensions/cloudwatch.yml.config @@ -0,0 +1,98 @@ +files: + "/opt/aws/amazon-cloudwatch-agent/bin/config.json": + mode: "000600" + owner: root + group: root + content: | + { + "agent": { + "metrics_collection_interval": 60, + "run_as_user": "root" + }, + "metrics": { + "namespace": "PAVI/ApiServer", + "append_dimensions": { + "AutoScalingGroupName": "${aws:AutoScalingGroupName}", + "InstanceId": "${aws:InstanceId}", + "InstanceType": "${aws:InstanceType}" + }, + "metrics_collected": { + "cpu": { + "totalcpu": true, + "measurement": [ + "usage_active", + "usage_idle", + "usage_iowait", + "usage_guest", + "usage_system", + "usage_user" + ] + }, + "disk": { + "ignore_file_system_types": [ + "tmpfs", + "devtmpfs" + ], + "measurement": [ + "free", + "used", + "used_percent", + "inodes_used", + "inodes_free" + ] + }, + "diskio": { + "measurement": [ + "reads", + "writes", + "read_bytes", + "write_bytes", + "iops_in_progress" + ] + }, + "mem": { + "measurement": [ + "available", + "available_percent", + "free", + "used", + "used_percent" + ] + }, + "swap": { + "measurement": [ + "free", + "used", + "used_percent" + ] + }, + "net": { + "resources": ["eth0", "docker0"], + "measurement": [ + "bytes_sent", + "bytes_recv", + "drop_in", + "drop_out", + "err_in", + "err_out" + ] + }, + "processes": { + "measurement": [ + "blocked", + "dead", + "paging", + "running", + "sleeping", + "wait", + "zombies", + "total", + "total_threads" + ] + } + } + } + } +container_commands: + start_cloudwatch_agent: + command: /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s -c file:/opt/aws/amazon-cloudwatch-agent/bin/config.json diff --git a/api/aws_infra/.ebextensions/eb-platform.yml.config b/api/aws_infra/.ebextensions/eb-platform.yml.config new file mode 100644 index 00000000..753659e9 --- /dev/null +++ b/api/aws_infra/.ebextensions/eb-platform.yml.config @@ -0,0 +1,7 @@ +option_settings: + aws:elasticbeanstalk:managedactions: + ManagedActionsEnabled: true + PreferredStartTime: "Tue:02:00" + aws:elasticbeanstalk:managedactions:platformupdate: + UpdateLevel: minor + InstanceRefreshEnabled: false diff --git a/api/aws_infra/.ebextensions/loadbalancer.yml.config b/api/aws_infra/.ebextensions/loadbalancer.yml.config new file mode 100644 index 00000000..66d4ef30 --- /dev/null +++ b/api/aws_infra/.ebextensions/loadbalancer.yml.config @@ -0,0 +1,35 @@ +Resources: + AWSEBV2LoadBalancerListener: + Type: 'AWS::ElasticLoadBalancingV2::Listener' + Properties: + DefaultActions: + - Type: redirect + RedirectConfig: + Protocol: HTTPS + Port: '443' + Host: '#{host}' + Path: '/#{path}' + Query: '#{query}' + StatusCode: HTTP_301 + LoadBalancerArn: + Ref: AWSEBV2LoadBalancer + Port: 80 + Protocol: HTTP +option_settings: + # As noted in the AWS docs, the following option cannot be set through the .ebextensions configuration files, + # and thus is defined directly in the CDK definitions (see parent directory). + # + # aws:elasticbeanstalk:environment: + # LoadBalancerType: application + # + # https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/environments-cfg-alb.html#environments-cfg-alb-namespaces + + aws:elbv2:listener:443: + SSLCertificateArns: arn:aws:acm:us-east-1:100225593120:certificate/047a56a2-09dd-4857-9f28-32d23650d4da + Protocol: HTTPS + DefaultProcess: api + aws:elasticbeanstalk:environment:process:api: + Port: '8080' + Protocol: HTTP + aws:elbv2:loadbalancer: + IdleTimeout: 600 diff --git a/api/aws_infra/.gitignore b/api/aws_infra/.gitignore index 20df6a6a..9127d784 100644 --- a/api/aws_infra/.gitignore +++ b/api/aws_infra/.gitignore @@ -8,6 +8,9 @@ node_modules/ package.json package-lock.json +## Deploy artefacts +eb_app.zip + # Python files ## Byte-compiled / optimized / DLL files __pycache__/ diff --git a/api/aws_infra/Makefile b/api/aws_infra/Makefile index a4503628..95e01ede 100644 --- a/api/aws_infra/Makefile +++ b/api/aws_infra/Makefile @@ -1,6 +1,16 @@ .PHONY: check-venv-active check-node + SUPPORTED_NODE := ^v18\. +AWS_DEFAULT_REGION := us-east-1 +AWS_ACCT_NR := 100225593120 + +PAVI_DEPLOY_VERSION_LABEL ?= $(shell git describe --tags --dirty)-$(shell git rev-parse --abbrev-ref HEAD)-$(shell date +%Y%m%d-%H%M%S) +DEPLOY_ENVIRONMENT ?= PaviApiEbDevStack +PAVI_IMAGE_TAG ?= main +PAVI_IMAGE_REGISTRY ?= ${AWS_ACCT_NR}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/ +ADD_CDK_ARGS ?= + check-venv-active: ifeq ($(VIRTUAL_ENV),) @echo 'No active python virtual environment found.'\ @@ -27,7 +37,7 @@ python-dependencies: python-test-dependencies: pip install -r tests/requirements.txt -run-unit-tests: python-dependencies python-test-dependencies +run-unit-tests: python-dependencies python-test-dependencies check-node python -m pytest run-unit-tests-dev: check-venv-active run-unit-tests @@ -39,9 +49,33 @@ run-python-type-check: python-dependencies python-test-dependencies run-python-style-check: python-dependencies python-test-dependencies flake8 ./ -validate: check-node run-unit-tests -# Validate production stack code +validate-image-stack: check-node run-unit-tests cdk diff PaviApiImageRepoCdkStack -validate-dev: check-venv-active validate +validate-application-stack: check-node run-unit-tests + cdk diff PaviApiEbApplicationCdkStack + +validate-environment-stack: check-node run-unit-tests + export PAVI_DEPLOY_VERSION_LABEL=${PAVI_DEPLOY_VERSION_LABEL} && \ + export PAVI_IMAGE_TAG=${PAVI_IMAGE_TAG} && \ + export PAVI_IMAGE_REGISTRY=${PAVI_IMAGE_REGISTRY} && \ + cdk diff PaviApiEbDevStack + +validate-all: check-node run-unit-tests validate-image-stack validate-application-stack validate-environment-stack @: + +validate-all-dev: check-venv-active validate-all + @: + +deploy-application: + cdk deploy PaviApiEbApplicationCdkStack ${ADD_CDK_ARGS} + python -m aws_helpers.deploy_eb_app_version --eb_app_name PAVI-api --version_label ${PAVI_DEPLOY_VERSION_LABEL} + +deploy-environment: + export PAVI_DEPLOY_VERSION_LABEL=${PAVI_DEPLOY_VERSION_LABEL} && \ + export PAVI_IMAGE_TAG=${PAVI_IMAGE_TAG} && \ + export PAVI_IMAGE_REGISTRY=${PAVI_IMAGE_REGISTRY} && \ + cdk deploy ${DEPLOY_ENVIRONMENT} ${ADD_CDK_ARGS} + +print-deploy-version-label: + @echo ${PAVI_DEPLOY_VERSION_LABEL} diff --git a/api/aws_infra/__init__.py b/api/aws_infra/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/aws_infra/aws_helpers/__init__.py b/api/aws_infra/aws_helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/aws_infra/aws_helpers/deploy_eb_app_version.py b/api/aws_infra/aws_helpers/deploy_eb_app_version.py new file mode 100644 index 00000000..d61b386c --- /dev/null +++ b/api/aws_infra/aws_helpers/deploy_eb_app_version.py @@ -0,0 +1,58 @@ +import click +from os import listdir, path +from zipfile import ZipFile + +from .eb.eb_app_version import eb_app_version_exists, create_eb_app_version +from .s3.eb_assets import upload_application_bundle + + +@click.command(context_settings={'show_default': True}) +@click.option("--eb_app_name", type=click.STRING, required=True, + help="The Elasticbeanstalk application name to deploy a new version for.") +@click.option("--version_label", type=click.STRING, required=True, + help="The version label to assign to the EB application version.") +def main(eb_app_name: str, version_label: str) -> None: + """ + Main method to deploy EB application versions. Receives input args from click. + + Checks if an EB application version already exists with the defined version_label, + and deploys the current working directory as a new application version with that label if not. + """ + ## Search EB application version by label + ## Note: EB application version management is done external to CDK, + ## as Cloudformation/CDK does not support custom labels at current (2024/05/17). + if not eb_app_version_exists(eb_app_name=eb_app_name, version_label=version_label): + print(f'Creating new application version with label "{version_label}".') + # Create app zip + dir_path = path.dirname(path.realpath(__file__)) + app_zip_path = 'eb_app.zip' + with ZipFile(app_zip_path, 'w') as zipObj: + ## Add docker-compose file + docker_compose_file = f'{dir_path}/../../docker-compose.yml' + zipObj.write(docker_compose_file, path.basename(path.normpath(docker_compose_file))) + + ## Add all files in .ebextensions/ + ebextensions_path = f'{dir_path}/../.ebextensions/' + for filename in listdir(ebextensions_path): + full_file_path = path.join(ebextensions_path, filename) + if path.isfile(full_file_path): + zipObj.write(full_file_path, path.join('.ebextensions/', filename)) + + # Upload app zip as s3 source bundle + source_bundle = upload_application_bundle( + eb_app_name=eb_app_name, + version_label=version_label, + bundle_path=app_zip_path) + + # Create new application version with label + create_eb_app_version( + eb_app_name=eb_app_name, version_label=version_label, + source_bundle=source_bundle, + tags=[{'Key': 'Product', 'Value': 'PAVI'}, + {'Key': 'Managed_by', 'Value': 'PAVI'}]) + else: + print(f'Application version with label "{version_label}" already exists.') + + +if __name__ == '__main__': + main() diff --git a/api/aws_infra/aws_helpers/eb/__init__.py b/api/aws_infra/aws_helpers/eb/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/aws_infra/aws_helpers/eb/eb_app_version.py b/api/aws_infra/aws_helpers/eb/eb_app_version.py new file mode 100644 index 00000000..a6b38067 --- /dev/null +++ b/api/aws_infra/aws_helpers/eb/eb_app_version.py @@ -0,0 +1,137 @@ +''' +Module containing helper functions to interact with AWS Elasticbeanstalk +Note: these functions are calling AWS synchronously (so will search/modify AWS resources instantly). +''' + +from boto3 import client +from time import sleep +from typing import Any + +boto3_eb_client = client('elasticbeanstalk') + + +def describe_app_version(version_label: str, eb_app_name: str) -> dict[Any, Any] | None: + ''' + Describe EB app version with given label. + + Args: + eb_app_name: name of the application to search a version of + version_label: version label to search for + + Returns: + dict representation of EB app version if found, None otherwise. + + Raises: + Exception: when >1 matching application versions are found. + ''' + + ## Search Application version by label + search_app_version_response: dict[Any, Any] = boto3_eb_client.describe_application_versions( + ApplicationName=eb_app_name, + VersionLabels=[version_label] + ) + + found_app_versions: list[dict[Any, Any]] = search_app_version_response['ApplicationVersions'] + + if len(found_app_versions) > 1: + raise Exception(f'Unexpected number of version ({len(found_app_versions)} > 1) matching label {version_label} in application {eb_app_name}.') + elif len(found_app_versions) == 1: + return found_app_versions.pop() + else: + return None + + +def eb_app_version_exists(version_label: str, eb_app_name: str) -> bool: + ''' + Search EB app version with given label. Return true if found, false otherwise. + + Args: + eb_app_name: name of the application to search a version of + version_label: version label to search for + + Returns: + Boolean indicating if version was found or not. + ''' + + ## Search Application version by label + app_version = describe_app_version(version_label=version_label, eb_app_name=eb_app_name) + + if app_version is not None: + return True + else: + return False + + +def get_eb_app_version_status(version_label: str, eb_app_name: str) -> str: + ''' + Search EB app version with given label and return its status. + + Args: + eb_app_name: name of the application to search a version of + version_label: version label to search for + + Returns: + Status of the application version + + Raises: + Exception: when failing to find or process application version. + ''' + + ## Search Application version by label + app_version = describe_app_version(version_label=version_label, eb_app_name=eb_app_name) + + if app_version is None: + raise Exception(f'No application version found with label {version_label} in application {eb_app_name}') + else: + app_version_status: str = app_version['Status'] + return app_version_status.lower() + + +def create_eb_app_version(version_label: str, eb_app_name: str, + source_bundle: dict[str, str], tags: list[dict[str, str]] = []) -> dict[Any, Any] | None: + ''' + Create EB app version with given label. + + Args: + eb_app_name: name of the application to create a version of + version_label: version label to create (should not exists) + source_bundle: EB source bundle to deploy + source_bundle: Optional list of tags to add to the application version + + Returns: + JSON object describing the created app version on success. None on failure. + + Raises: + Exception: on any failure occured during application version creation. + ''' + + ## Create new application version with label + create_app_version_response: dict[Any, Any] = boto3_eb_client.create_application_version( + ApplicationName=eb_app_name, + VersionLabel=version_label, + SourceBundle=source_bundle, + Process=True, + Tags=tags + ) + + application_version_status: str = create_app_version_response['ApplicationVersion']['Status'] + application_version_status = application_version_status.lower() + + # Wait for application version to be fully processed and validated + TIMEOUT = 120 + INTERVAL = 10 + walltime = 0 + while application_version_status in ['processing', 'building'] and walltime < TIMEOUT: + sleep(INTERVAL) + walltime += INTERVAL + + application_version_status = get_eb_app_version_status(eb_app_name=eb_app_name, version_label=version_label) + + if walltime >= TIMEOUT: + raise Exception('EB app version failed to report creation completion before timeout.') + elif application_version_status == 'failed': + raise Exception('EB app version creation failed.') + elif application_version_status != 'processed': + raise Exception(f'Unexpected EB app version status "{application_version_status}" reported.') + else: + return describe_app_version(version_label=version_label, eb_app_name=eb_app_name) diff --git a/api/aws_infra/aws_helpers/s3/__init__.py b/api/aws_infra/aws_helpers/s3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/aws_infra/aws_helpers/s3/eb_assets.py b/api/aws_infra/aws_helpers/s3/eb_assets.py new file mode 100644 index 00000000..4e9da045 --- /dev/null +++ b/api/aws_infra/aws_helpers/s3/eb_assets.py @@ -0,0 +1,53 @@ +''' +Module containing helper functions to interact with AWS S3, for support of Elasticbeanstalk resources. +Note: these functions are calling AWS synchronously (so will search/modify AWS resources instantly). +''' + +import boto3 +from botocore.exceptions import ClientError + +aws_account_nr = boto3.client('sts').get_caller_identity().get('Account') +AWS_REGION = 'us-east-1' + +eb_s3_bucket_name = f'elasticbeanstalk-{AWS_REGION}-{aws_account_nr}' + +s3_resources = boto3.resource('s3') +eb_s3_bucket = s3_resources.Bucket(eb_s3_bucket_name) + + +def upload_application_bundle(eb_app_name: str, version_label: str, bundle_path: str) -> dict[str, str]: + ''' + Uploads an EB application bundle to S3 for use with EB. + + Args: + eb_app_name: name of the application to create a version for + version_label: EB version label to upload a bundle for + bundle_path: path to application bundle to uplaod + + Returns: + The source bundle to be used for creating a new EB application version with the uploaded assets. + + Raises: + Exception: when upload failed. + ''' + source_bundle_path = f'{eb_app_name}/{version_label}.zip' + s3_object = eb_s3_bucket.Object(source_bundle_path) + + # Throw exception if source_bundle_path already exists + try: + s3_object.load() + except ClientError: + # No object found at source_bundle_path, proceed uploading + pass + else: + raise Exception(f'Source bundle for {version_label} already found at "{source_bundle_path}".') + + # Upload the sourcebundle + try: + s3_object.upload_file(bundle_path) + except ClientError: + raise Exception('Exception caught while uploading bundle to S3.') + + return { + 'S3Bucket': eb_s3_bucket_name, + 'S3Key': source_bundle_path} diff --git a/api/aws_infra/cdk_app.py b/api/aws_infra/cdk_app.py index 3a3bcdfc..e1dc386e 100644 --- a/api/aws_infra/cdk_app.py +++ b/api/aws_infra/cdk_app.py @@ -4,7 +4,8 @@ from pathlib import Path from sys import path as sys_path -from cdk_classes.cdk_image_repo_stack import CdkImageRepoStack +from cdk_classes.image_repo_stack import CdkImageRepoStack +from cdk_classes.application_stack import EBApplicationCdkStack, EbEnvironmentCdkStack repo_root_path = Path(__file__).parent.parent.parent.parent sys_path.append(str(repo_root_path)) @@ -13,11 +14,24 @@ app = App() -CdkImageRepoStack(app, "PaviApiImageRepoCdkStack", - env=agr_aws_environment) - -CdkImageRepoStack(app, "PaviApiImageRepoCdkStack-dev", env_suffix="dev", - shared_api_image_repo='agr_pavi/api', - env=agr_aws_environment) +CdkImageRepoStack( + app, "PaviApiImageRepoCdkStack", + env=agr_aws_environment) + +eb_app_stack = EBApplicationCdkStack( + app, "PaviApiEbApplicationCdkStack", + env=agr_aws_environment) + +EbEnvironmentCdkStack( + app, "PaviApiEbMainStack", + eb_app_stack=eb_app_stack, + env_suffix='main', + env=agr_aws_environment) + +EbEnvironmentCdkStack( + app, "PaviApiEbDevStack", + eb_app_stack=eb_app_stack, + env_suffix='dev', + env=agr_aws_environment) app.synth() diff --git a/api/aws_infra/cdk_classes/application_stack.py b/api/aws_infra/cdk_classes/application_stack.py new file mode 100644 index 00000000..dd3e912f --- /dev/null +++ b/api/aws_infra/cdk_classes/application_stack.py @@ -0,0 +1,149 @@ +from aws_cdk import ( + aws_elasticbeanstalk as eb, + aws_iam as iam, + Stack, + Tags as cdk_tags +) + +from constructs import Construct + +from os import getenv +from typing import Any + + +class EBApplicationCdkStack(Stack): + + eb_application: eb.CfnApplication + + def __init__(self, scope: Construct, construct_id: str, **kwargs: Any) -> None: + """ + Args: + scope: CDK scope + construct_id: ID used to uniquely identify construct withing the given scope + """ + super().__init__(scope, construct_id, **kwargs) + + eb_service_role = iam.Role.from_role_name( + self, id='eb-service-role', + role_name='aws-elasticbeanstalk-service-role') + + # Define application version removal policy, to prevent failing deployments caused by + # accumulating application versions over the account-level limit (1000 on 2023/05/16) + version_removal_policy = eb.CfnApplication.ApplicationVersionLifecycleConfigProperty( + max_count_rule=eb.CfnApplication.MaxCountRuleProperty( + delete_source_from_s3=True, + enabled=True, + # max_count = number of application versions to retain, BEFORE new env creation. + # Total will be max_count+1 after update completed. + max_count=2)) + + # Create EB application + self.eb_application = eb.CfnApplication( + self, id='PAVI-api-eb-app', application_name='PAVI-api', + resource_lifecycle_config=eb.CfnApplication.ApplicationResourceLifecycleConfigProperty( + service_role=eb_service_role.role_arn, + version_lifecycle_config=version_removal_policy + )) + + cdk_tags.of(self.eb_application).add("Product", "PAVI") # type: ignore + cdk_tags.of(self.eb_application).add("Managed_by", "PAVI") # type: ignore + + +class EbEnvironmentCdkStack(Stack): + + eb_instance_profile: iam.InstanceProfile + eb_env: eb.CfnEnvironment + + def __init__(self, scope: Construct, construct_id: str, + eb_app_stack: EBApplicationCdkStack, + env_suffix: str, + **kwargs: Any) -> None: + """ + Args: + scope: CDK scope + construct_id: ID used to uniquely identify construct withing the given scope + eb_app_stack: CdkEBApplicationStack defining the EB application to deploy to + env_suffix: environment suffix, added to created resource names + """ + super().__init__(scope, construct_id, **kwargs) + + eb_service_role = iam.Role.from_role_name( + self, id='eb-service-role', + role_name='aws-elasticbeanstalk-service-role') + + # Define role and instance profile + eb_ec2_role = iam.Role( + self, 'eb-ec2-role', + # role_name=f'{eb_application_name}-aws-elasticbeanstalk-ec2-role', + assumed_by=iam.ServicePrincipal('ec2.amazonaws.com'), # type: ignore + managed_policies=[ + iam.ManagedPolicy.from_aws_managed_policy_name('AWSElasticBeanstalkWebTier'), + iam.ManagedPolicy.from_aws_managed_policy_name('CloudWatchAgentServerPolicy'), + iam.ManagedPolicy.from_managed_policy_name(self, "iam-ecr-read-policy", "ReadOnlyAccessECR")]) + cdk_tags.of(eb_ec2_role).add("Product", "PAVI") # type: ignore + cdk_tags.of(eb_ec2_role).add("Managed_by", "PAVI") # type: ignore + + self.eb_instance_profile = iam.InstanceProfile( + self, 'eb-instance-profile', + # instance_profile_name=f'{eb_application_name}-InstanceProfile', + role=eb_ec2_role) # type: ignore + cdk_tags.of(self.eb_instance_profile).add("Product", "PAVI") # type: ignore + cdk_tags.of(self.eb_instance_profile).add("Managed_by", "PAVI") # type: ignore + + eb_app_name = str(eb_app_stack.eb_application.application_name) + app_version_label = getenv('PAVI_DEPLOY_VERSION_LABEL') + + # Create EB environment to run the application + # Environment-defined settings are defined here, + # Settings that are bundeled into the application version are defined in .ebextensions/ + optionSettingProperties: list[eb.CfnEnvironment.OptionSettingProperty] = [ + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:environment', + option_name='LoadBalancerType', + value='application' + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:environment', + option_name='ServiceRole', + value=eb_service_role.role_arn + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:autoscaling:launchconfiguration', + option_name='IamInstanceProfile', + value=self.eb_instance_profile.instance_profile_name + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:autoscaling:launchconfiguration', + option_name='EC2KeyName', + value='AGR-ssl2' + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:application:environment', + option_name='AGR_PAVI_RELEASE', + value=getenv('PAVI_IMAGE_TAG') + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:application:environment', + option_name='API_PIPELINE_IMAGE_TAG', + value=getenv('PAVI_IMAGE_TAG') + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:application:environment', + option_name='REGISTRY', + value=getenv('PAVI_IMAGE_REGISTRY') + ), + eb.CfnEnvironment.OptionSettingProperty( + namespace='aws:elasticbeanstalk:application:environment', + option_name='NET', + value=env_suffix + ) + ] + + self.eb_env = eb.CfnEnvironment(self, 'eb-environment', + environment_name=f'{eb_app_name}-{env_suffix}', + application_name=eb_app_name, + solution_stack_name='64bit Amazon Linux 2023 v4.3.1 running Docker', + version_label=app_version_label, + option_settings=optionSettingProperties) + cdk_tags.of(self.eb_env).add("Product", "PAVI") # type: ignore + cdk_tags.of(self.eb_env).add("Managed_by", "PAVI") # type: ignore diff --git a/api/aws_infra/cdk_classes/cdk_image_repo_stack.py b/api/aws_infra/cdk_classes/image_repo_stack.py similarity index 100% rename from api/aws_infra/cdk_classes/cdk_image_repo_stack.py rename to api/aws_infra/cdk_classes/image_repo_stack.py diff --git a/api/aws_infra/requirements.txt b/api/aws_infra/requirements.txt index cf66925d..6f2ad7ab 100644 --- a/api/aws_infra/requirements.txt +++ b/api/aws_infra/requirements.txt @@ -1,2 +1,6 @@ +# Helper requirements +boto3==1.34.* +click==8.1.* +# CDK requirements aws-cdk-lib==2.141.* constructs==10.0.0,<11.0.0 diff --git a/api/aws_infra/tests/requirements.txt b/api/aws_infra/tests/requirements.txt index 81cc76c1..bfbc44b0 100644 --- a/api/aws_infra/tests/requirements.txt +++ b/api/aws_infra/tests/requirements.txt @@ -1,3 +1,4 @@ +boto3-stubs==1.34.* flake8==7.0.* mypy==1.10.* pytest==8.2.* diff --git a/api/aws_infra/tests/unit/test_cdk_application_stack.py b/api/aws_infra/tests/unit/test_cdk_application_stack.py new file mode 100644 index 00000000..a4a55f16 --- /dev/null +++ b/api/aws_infra/tests/unit/test_cdk_application_stack.py @@ -0,0 +1,53 @@ +""" +Unit testing for the cdk_image_repo_stack, +to ensure breaking changes are caught and handled +before getting applied to the live AWS resources. +""" + +from aws_cdk import App +from aws_cdk.aws_config import ResourceType +import aws_cdk.assertions as assertions + +from cdk_classes.application_stack import EBApplicationCdkStack, EbEnvironmentCdkStack + +from pathlib import Path +from sys import path as sys_path + +print(__file__) +repo_root_path = Path(__file__).parent.parent.parent.parent.parent +print(repo_root_path) +sys_path.append(str(repo_root_path)) + +from shared_aws_infra.agr_aws_env import agr_aws_environment # noqa: E402 + +app = App() +eb_app_stack = EBApplicationCdkStack(app, "pytest-api-EB-Application-stack", env=agr_aws_environment) + +eb_env_stack = EbEnvironmentCdkStack( + app, "pytest-api-env-stack", eb_app_stack, 'pytest', env=agr_aws_environment) + +eb_app_template = assertions.Template.from_stack(eb_app_stack) +eb_env_template = assertions.Template.from_stack(eb_env_stack) + + +# If below application name changes, then ensure this change is intentional. +# Such change may possibility break deploying the same application version to multiple environments +def test_eb_application() -> None: + eb_app_template.has_resource(type=ResourceType.ELASTIC_BEANSTALK_APPLICATION.compliance_resource_type, props={ + "Properties": { + "ApplicationName": "PAVI-api" + } + }) + + +# If below environment name changes, then ensure this change is intentional. +# The environment name change could potentially break DNS rules or have unexpected deployment consequences +# (several environment getting mashed together if prefixing did not happen appropriately) +# All EB environments must belong to 'PAVI-api' EB application. +def test_eb_app_version() -> None: + eb_env_template.has_resource(type=ResourceType.ELASTIC_BEANSTALK_ENVIRONMENT.compliance_resource_type, props={ + "Properties": { + "ApplicationName": "PAVI-api", + "EnvironmentName": "PAVI-api-pytest" + } + }) diff --git a/api/aws_infra/tests/unit/test_cdk_image_repo_stack.py b/api/aws_infra/tests/unit/test_cdk_image_repo_stack.py index 4ad0dba2..ef481d17 100644 --- a/api/aws_infra/tests/unit/test_cdk_image_repo_stack.py +++ b/api/aws_infra/tests/unit/test_cdk_image_repo_stack.py @@ -1,5 +1,5 @@ """ -Unit testing for the cdk_infra_stack, +Unit testing for the cdk_image_repo_stack, to ensure breaking changes are caught and handled before getting applied to the live AWS resources. """ @@ -8,12 +8,12 @@ from aws_cdk.aws_config import ResourceType import aws_cdk.assertions as assertions -from cdk_classes.cdk_image_repo_stack import CdkImageRepoStack +from cdk_classes.image_repo_stack import CdkImageRepoStack from pathlib import Path from sys import path as sys_path -repo_root_path = Path(__file__).parent.parent.parent.parent +repo_root_path = Path(__file__).parent.parent.parent.parent.parent sys_path.append(str(repo_root_path)) from shared_aws_infra.agr_aws_env import agr_aws_environment # noqa: E402 diff --git a/api/docker-compose-dev.yml b/api/docker-compose-dev.yml new file mode 100644 index 00000000..767617c5 --- /dev/null +++ b/api/docker-compose-dev.yml @@ -0,0 +1,26 @@ +version: "3.2" + +services: + + agr.pavi.dev-local.api: + container_name: agr.pavi.dev-local.api.server + image: ${REGISTRY}agr_pavi/api:${AGR_PAVI_RELEASE} + ports: + - "8080:8080" + networks: + - pavi + environment: + - AWS_PROFILE + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + - AWS_ROLE_ARN + - AWS_ROLE_SESSION_NAME + - AWS_SESSION_TOKEN + - AWS_WEB_IDENTITY_TOKEN_FILE + - API_PIPELINE_IMAGE_TAG + volumes: + - ~/.aws:/root/.aws + +networks: + # The presence of these objects is sufficient to define them + pavi: diff --git a/api/docker-compose.yml b/api/docker-compose.yml index ea8e881c..67764a96 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -10,25 +10,6 @@ services: networks: - pavi - agr.pavi.dev.api: - container_name: agr.pavi.dev.api.server - image: ${REGISTRY}agr_pavi/api:${AGR_PAVI_RELEASE} - ports: - - "8080:8080" - networks: - - pavi - environment: - - AWS_PROFILE - - AWS_ACCESS_KEY_ID - - AWS_SECRET_ACCESS_KEY - - AWS_ROLE_ARN - - AWS_ROLE_SESSION_NAME - - AWS_SESSION_TOKEN - - AWS_WEB_IDENTITY_TOKEN_FILE - - API_PIPELINE_IMAGE_TAG - volumes: - - ~/.aws:/root/.aws - networks: # The presence of these objects is sufficient to define them pavi: diff --git a/shared_aws_infra/mypy.ini b/shared_aws_infra/mypy.ini new file mode 100644 index 00000000..98f22f72 --- /dev/null +++ b/shared_aws_infra/mypy.ini @@ -0,0 +1,11 @@ +[mypy] +disallow_any_generics = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +warn_unused_ignores = False +warn_return_any = True +strict_equality = True +enable_error_code = explicit-override,truthy-bool,truthy-iterable,possibly-undefined +follow_imports = skip +exclude = venv