├── .github └── workflows │ └── style.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── example-settings.json ├── python-docstring-template.mustache └── settings.json ├── Justfile ├── LICENSE.txt ├── README.md ├── docs ├── Untitled Diagram.drawio ├── api-gateway-metrics.png ├── api-gateway-trace.png ├── correlated-logs.png ├── free-tier │ ├── api-gateway.png │ ├── cloudfront.png │ ├── cloudtrail.png │ ├── cloudwatch.png │ ├── cognito.png │ ├── lambda.png │ ├── s3.png │ └── x-ray.png └── openapi.png ├── infrastructure ├── .gitignore ├── MANIFEST.in ├── README.md ├── app.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src │ └── fastapi_iac │ │ ├── __init__.py │ │ ├── rest_api.py │ │ └── stack.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_auth.py │ └── test_example.py ├── linting ├── .flake8 ├── .isort.cfg ├── .pydocstyle.cfg └── .pylintrc ├── otel-playground ├── README.md ├── log.py ├── requirements.txt └── run-log.sh ├── rest-api ├── Dockerfile ├── Justfile ├── README.md ├── aws-lambda │ ├── index.py │ └── requirements.txt ├── docker-compose.yml ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── src │ └── example_rest_api │ │ ├── __init__.py │ │ ├── aws │ │ ├── __init__.py │ │ └── s3.py │ │ ├── errors.py │ │ ├── main.py │ │ ├── middlewares.py │ │ ├── routes │ │ ├── __init__.py │ │ ├── docs.py │ │ └── files.py │ │ ├── schemas │ │ ├── __init__.py │ │ ├── files.py │ │ └── services.py │ │ ├── services │ │ ├── __init__.py │ │ ├── file_manager.py │ │ └── service.py │ │ └── settings.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── fixtures │ ├── __init__.py │ ├── settings.py │ └── state_machine.py │ ├── functional_tests │ ├── __init__.py │ └── test_descriptor_routes.py │ └── unit_tests │ ├── __init__.py │ └── test_settings.py └── settings.json /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v3 14 | - name: checkout trunk and current branches 15 | run: | 16 | # these steps are necessary for darker to compare diffs properly fetches all remote branches 17 | git fetch 18 | # creates a local branch of rootski remote default trunk branch 19 | git checkout -b trunk origin/trunk || echo "trunk already exists" 20 | # creates a local branch of the current running branch in pipeline 21 | git checkout -b ${GITHUB_HEAD_REF} origin/${GITHUB_HEAD_REF} || echo "${GITHUB_HEAD_REF} already exists" 22 | - uses: pre-commit/action@v3.0.0 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *node_modules/ 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/.vscode/example-settings.json 7 | venv 8 | *cache* 9 | .env 10 | metaflow-repos 11 | .metaflow 12 | **cdk.out/ 13 | **cdk.context.json 14 | .vscode 15 | !.vscode/python-docstring-template.mustache 16 | !.vscode/example-settings.json 17 | !.vscode/settings.json 18 | *.env 19 | *.idea 20 | *.egg-info 21 | *build/ 22 | *build_/ 23 | *dist/ 24 | *htmlcov 25 | *test-reports 26 | *.coverage 27 | *coverage.xml -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ############################ 2 | # --- Pre-Commit Hooks --- # 3 | ############################ 4 | 5 | # A few good resources: 6 | # file where hooks are installed venv: https://github.com/getsentry/sentry/blob/master/.pre-commit-config.yaml 7 | 8 | # for simplicity, we exclude files generated by projen 9 | x-exclude-projen: &projen-exclude-pattern ^(.*projen.*|.vscode/example-settings.json|.*setup.cfg|.*setup.py|.*pyproject.toml|.*gitignore) 10 | 11 | repos: 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v3.2.0 14 | hooks: 15 | # Fails if there are any ">>>>>" lines in files due to merge conflicts. 16 | - id: check-merge-conflict 17 | # Trims trailing whitespace. Allow a single space on the end of .md lines for hard line breaks. 18 | - id: trailing-whitespace 19 | exclude: *projen-exclude-pattern 20 | args: [--markdown-linebreak-ext=md] 21 | # Makes sure files end in a newline and only a newline; 22 | # we include CSV since a lot of the files already in our git LFS store are csv and json 23 | - id: end-of-file-fixer 24 | exclude: *projen-exclude-pattern 25 | exclude_types: [csv, svg] 26 | # Attempts to load all TOML files to verify syntax. 27 | - id: check-toml 28 | # Attempts to load all yaml files to verify syntax; unsafe: only check syntax, do not load yaml 29 | - id: check-yaml 30 | args: ["--unsafe"] 31 | # Check for symlinks that do not point to anything. 32 | - id: check-symlinks 33 | # Fail if staged files are above a certain size. 34 | # To add a large file, use 'git lfs track ; git add to track large files with 35 | # git-lfs rather than commiting them directly to the git history 36 | - id: check-added-large-files 37 | args: ["--maxkb=500"] 38 | # HALT! Before you exclude a large file and commit it, forever 39 | # bloating our repo size, did you: 40 | # (1) use a CLI tool like imageoptim to compress them if they are images 41 | # (2) think hard about whether using DVC or git-lfs is more appropriate 42 | # for the file--such as in the case of CSV files or other data 43 | # This can be confusing. Reach out for help in our chat to help decide 44 | # how to deal adding these large files you have :) 45 | exclude: | 46 | (?x)( 47 | ^path/to/some/big/file.csv| 48 | ^path/to/another/big/file.csv 49 | ) 50 | # Sort requirements in requirements.txt files. 51 | - id: requirements-txt-fixer 52 | exclude: *projen-exclude-pattern 53 | # Prevent addition of new git submodules. 54 | - id: forbid-new-submodules 55 | # Prevent committing directly to trunk; create a feature branch for your changes with 56 | # 'git checkout -b feat/my-new-feature' and then commit these changes to that branch 57 | - id: no-commit-to-branch 58 | args: ["--branch=trunk"] 59 | # # Detects *your* aws credentials from your ~/.aws/credentials file 60 | # - id: detect-aws-credentials 61 | # Detects the presence of private keys 62 | - id: detect-private-key 63 | 64 | # - repo: https://github.com/Yelp/detect-secrets 65 | # rev: v1.1.0 66 | # hooks: 67 | # # compare "high entropy" strings found in our code with the last time we did this 68 | # - id: detect-secrets 69 | # args: [--baseline, .secrets.baseline] 70 | # exclude: package.lock.json 71 | 72 | # A few helpers for writing reStructuredText (in docstrings and sphinx docs) 73 | - repo: https://github.com/pre-commit/pygrep-hooks 74 | rev: v1.9.0 75 | hooks: 76 | # Detect common mistake of using single backticks when writing rst 77 | - id: rst-backticks 78 | # Detect mistake of rst directive not ending with double colon 79 | - id: rst-directive-colons 80 | # Detect mistake of inline code touching normal text in rst 81 | - id: rst-inline-touching-normal 82 | 83 | - repo: https://github.com/humitos/mirrors-autoflake.git 84 | rev: v1.3 85 | hooks: 86 | - id: autoflake 87 | language: system 88 | args: 89 | [ 90 | --in-place, 91 | --remove-all-unused-imports, 92 | --remove-unused-variable, 93 | --ignore-init-module-imports, 94 | ] 95 | 96 | 97 | - repo: https://github.com/akaihola/darker 98 | rev: 1.4.1 99 | hooks: 100 | # fail if black, pylint, flake8, isort, or pydocstyle find errors in the 'git --diff' 101 | # between this branch and latest commit on 'trunk'; this is great because it does not require 102 | # contributors to make changes to parts of the codebase they didn't change. Said otherwise: 103 | # if you submit a PR, the build will only fail if the code *you* wrote/changed does not 104 | # satisfy these quality check tools, but if there were already issues in the codebase before 105 | # you got there, the build will still pass and your PR can go through. 106 | - id: darker 107 | args: 108 | - --isort 109 | # executes flake8 and pydocstyle (where pydocstyle is a flake8 plugin) 110 | - -L flake8 --config=./linting/.flake8 111 | - -L pylint --rcfile=./linting/.pylintrc 112 | # line length for black 113 | - -l 112 114 | - --verbose 115 | additional_dependencies: 116 | - black==22.1.0 117 | - isort~=5.9 118 | - flake8~=4.0 119 | - pylint~=2.12 120 | - pydocstyle~=6.1 121 | # pydocstyle plugin for flake8 122 | - flake8-docstrings~=1.6 123 | entry: darker --revision trunk 124 | exclude: *projen-exclude-pattern 125 | -------------------------------------------------------------------------------- /.vscode/example-settings.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | { 3 | "restructuredtext.confPath": "", 4 | "autoDocstring.customTemplatePath": "./.vscode/python-docstring-template.mustache", 5 | "python.linting.pylintEnabled": true, 6 | "python.linting.pylintArgs": [ 7 | "--rcfile=./linting/.pylintrc" 8 | ], 9 | "python.formatting.provider": "black", 10 | "python.formatting.blackArgs": [ 11 | "--line-length=112" 12 | ], 13 | "python.linting.flake8Enabled": true, 14 | "python.linting.flake8Args": [ 15 | "--config==./linting/.flake8", 16 | "--max-line-length=112" 17 | ], 18 | "editor.formatOnSave": true 19 | } -------------------------------------------------------------------------------- /.vscode/python-docstring-template.mustache: -------------------------------------------------------------------------------- 1 | {{! Sphinx Docstring Template without Types for Args, Returns or Yields }} 2 | {{summaryPlaceholder}} 3 | 4 | {{extendedSummaryPlaceholder}} 5 | 6 | {{#args}} 7 | :param {{var}}: {{descriptionPlaceholder}} 8 | {{/args}} 9 | {{#kwargs}} 10 | :param {{var}}: {{descriptionPlaceholder}} 11 | {{/kwargs}} 12 | {{#exceptions}} 13 | :raises {{type}}: {{descriptionPlaceholder}} 14 | {{/exceptions}} 15 | {{#returns}} 16 | :return: {{descriptionPlaceholder}} 17 | {{/returns}} 18 | {{#yields}} 19 | :yield: {{descriptionPlaceholder}} 20 | {{/yields}} 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // isort: use the "isort" vs code extension, it will use the isort in the venv if available 3 | "isort.args": [ 4 | "--settings-path=${workspaceFolder}/rest-api/pyproject.toml" 5 | ], 6 | "black-formatter.args": [ 7 | "--config=${workspaceFolder}/rest-api/pyproject.toml" 8 | ], 9 | "[python]": { 10 | // "editor.defaultFormatter": "ms-python.black-formatter", 11 | "editor.codeActionsOnSave": { 12 | "source.organizeImports": true 13 | }, 14 | "editor.formatOnSave": true, 15 | }, 16 | "python.linting.pylintEnabled": true, 17 | "python.linting.pylintArgs": [ 18 | "--rcfile=${workspaceFolder}/rest-api/pyproject.toml", 19 | ], 20 | "python.formatting.provider": "black", 21 | "python.formatting.blackArgs": [ 22 | // "--line-length=112", 23 | "--config=${workspaceFolder}/rest-api/pyproject.toml" 24 | ], 25 | "python.linting.flake8Enabled": true, 26 | "python.linting.flake8Args": [ 27 | // '--toml-config' argument comes from the 'pip install Flake8-pyproject' plugin 28 | "--toml-config=${workspaceFolder}/rest-api/pyproject.toml", 29 | ], 30 | "python.analysis.autoImportCompletions": true, 31 | "python.analysis.indexing": true, // Found that somewhere on SO 32 | "python.languageServer": "Pylance", 33 | "python.analysis.completeFunctionParens": true, 34 | "python.linting.mypyEnabled": true, 35 | 36 | } -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | # This is a "Justfile". "just" is a task-runner similar to "make", but much less frustrating. 2 | # There is a VS Code extension for just that provides syntax highlighting. 3 | # 4 | # Execute any commands in this file by running "just ", e.g. "just install". 5 | 6 | set dotenv-load := true 7 | 8 | AWS_PROFILE := "mlops-club" 9 | AWS_REGION := "us-west-2" 10 | 11 | AWS_CDK_DIR := "./infrastructure" 12 | FAST_API_DIR := "./rest-api" 13 | 14 | # install the project's python packages and other useful 15 | install: require-venv 16 | # install useful VS Code extensions 17 | which code && code --install-extension njpwerner.autodocstring || echo "skipping install of autodocstring" 18 | which code && code --install-extension kokakiwi.vscode-just || echo "skipping install of vscode-just" 19 | cp .vscode/example-settings.json settings.json || echo ".vscode/settings.json already present" 20 | # install python packages not belonging to any particular package in this repo, 21 | # but important for development 22 | python -m pip install \ 23 | pre-commit \ 24 | phitoduck-projen \ 25 | black \ 26 | pylint \ 27 | flake8 \ 28 | mypy 29 | # install the minecraft-deployment package as an "editable" package 30 | python -m pip install -e {{AWS_CDK_DIR}}[all] -e {{FAST_API_DIR}}[all] 31 | # install pre-commit hooks to protect the quality of code committed by contributors 32 | pre-commit install 33 | # # install git lfs for downloading rootski CSVs and other large files in the repo 34 | # git lfs install 35 | 36 | 37 | cdk-deploy: #require-venv 38 | cd {{AWS_CDK_DIR}} \ 39 | && \ 40 | AWS_PROFILE={{AWS_PROFILE}} \ 41 | AWS_ACCOUNT_ID=$(just get-aws-account-id) \ 42 | CDK_DEFAULT_REGION={{AWS_REGION}} \ 43 | AWS_REGION={{AWS_REGION}} \ 44 | cdk deploy \ 45 | --all \ 46 | --diff \ 47 | --require-approval never \ 48 | --profile {{AWS_PROFILE}} \ 49 | --region {{AWS_REGION}} \ 50 | --app "python app.py" --hotswap 51 | 52 | # --require-approval any-change 53 | 54 | cdk-diff: #require-venv 55 | cd {{AWS_CDK_DIR}} \ 56 | && \ 57 | AWS_PROFILE={{AWS_PROFILE}} \ 58 | AWS_ACCOUNT_ID=$(just get-aws-account-id) \ 59 | CDK_DEFAULT_REGION={{AWS_REGION}} \ 60 | AWS_REGION={{AWS_REGION}} \ 61 | cdk diff \ 62 | --profile {{AWS_PROFILE}} \ 63 | --region {{AWS_REGION}} \ 64 | --app "python3 app.py" 65 | 66 | cdk-destroy: #require-venv 67 | cd {{AWS_CDK_DIR}} \ 68 | && \ 69 | AWS_PROFILE={{AWS_PROFILE}} \ 70 | AWS_ACCOUNT_ID=`just get-aws-account-id` \ 71 | CDK_DEFAULT_REGION={{AWS_REGION}} \ 72 | cdk destroy --all --diff --profile {{AWS_PROFILE}} --region {{AWS_REGION}} --app "python3 app.py" 73 | 74 | # generate CloudFormation from the code in "{{AWS_CDK_DIR}}" 75 | cdk-synth: require-venv #login-to-aws 76 | cd {{AWS_CDK_DIR}} && \ 77 | AWS_PROFILE={{AWS_PROFILE}} \ 78 | AWS_ACCOUNT_ID=$(just get-aws-account-id) \ 79 | CDK_DEFAULT_REGION={{AWS_REGION}} \ 80 | AWS_REGION={{AWS_REGION}} \ 81 | cdk synth --all --profile mlops-club --app "python3 app.py" 82 | 83 | open-aws: 84 | #!/bin/bash 85 | MLOPS_CLUB_SSO_START_URL="https://d-926768adcc.awsapps.com/start" 86 | open $MLOPS_CLUB_SSO_START_URL 87 | 88 | # Ensure that an "mlops-club" AWS CLI profile is configured. Then go through an AWS SSO 89 | # sign in flow to get temporary credentials for that profile. If this command finishes successfully, 90 | # you will be able to run AWS CLI commands against the MLOps club account using '--profile mlops-club' 91 | # WARNING: this login only lasts for 8 hours 92 | login-to-aws: 93 | #!/bin/bash 94 | MLOPS_CLUB_AWS_PROFILE_NAME="mlops-club" 95 | MLOPS_CLUB_AWS_ACCOUNT_ID="630013828440" 96 | MLOPS_CLUB_SSO_START_URL="https://d-926768adcc.awsapps.com/start" 97 | MLOPS_CLUB_SSO_REGION="us-west-2" 98 | 99 | # TODO: make this check work so we can uncomment it. It will make it so we only have to 100 | # open our browser if our log in has expired or we have not logged in before. 101 | # skip if already logged in 102 | # aws sts get-caller-identity --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} | cat | grep 'UserId' > /dev/null \ 103 | # && echo "[mlops-club] ✅ Logged in with aws cli" \ 104 | # && exit 0 105 | 106 | # configure an "[mlops-club]" profile in aws-config 107 | echo "[mlops-club] Configuring an AWS profile called '${MLOPS_CLUB_AWS_PROFILE_NAME}'" 108 | aws configure set sso_start_url ${MLOPS_CLUB_SSO_START_URL} --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} 109 | aws configure set sso_region ${MLOPS_CLUB_SSO_REGION} --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} 110 | aws configure set sso_account_id ${MLOPS_CLUB_AWS_ACCOUNT_ID} --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} 111 | aws configure set sso_role_name AdministratorAccess --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} 112 | aws configure set region ${MLOPS_CLUB_SSO_REGION} --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} 113 | 114 | # login to AWS using single-sign-on 115 | aws sso login --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} \ 116 | && echo '' \ 117 | && echo "[mlops-club] ✅ Login successful. AWS CLI commands will now work by adding the '--profile ${MLOPS_CLUB_AWS_PROFILE_NAME}' 😃" \ 118 | && echo " Your '${MLOPS_CLUB_AWS_PROFILE_NAME}' profile has temporary credentials using this identity:" \ 119 | && echo '' \ 120 | && aws sts get-caller-identity --profile ${MLOPS_CLUB_AWS_PROFILE_NAME} | cat 121 | 122 | # certain boilerplate files like setup.cfg, setup.py, and .gitignore are "locked"; 123 | # you can modify their contents by editing the .projenrc.py file in the root of the repo. 124 | update-boilerplate-files: require-venv 125 | python .projenrc.py 126 | 127 | # throw an error if a virtual environment isn't activated; 128 | # add this as a requirement to other targets that you want to ensure always run in 129 | # some kind of activated virtual environment 130 | require-venv: 131 | #!/usr/bin/env python 132 | import sys 133 | from textwrap import dedent 134 | 135 | def get_base_prefix_compat(): 136 | """Get base/real prefix, or sys.prefix if there is none.""" 137 | return getattr(sys, "base_prefix", None) or getattr(sys, "real_prefix", None) or sys.prefix 138 | 139 | def in_virtualenv(): 140 | return get_base_prefix_compat() != sys.prefix 141 | 142 | if not in_virtualenv(): 143 | print(dedent("""\ 144 | ⛔️ ERROR: 'just' detected that you have not activated a python virtual environment. 145 | 146 | Science has shown that installing python packages (e.g. 'pip install pandas') 147 | without a virtual environment increases likelihood of getting ulcers and COVID. 🧪👩‍🔬 148 | 149 | To resolve this error, please activate a virtual environment by running 150 | whichever of the following commands apply to you: 151 | 152 | ```bash 153 | # create a (virtual) copy of the python just for this project 154 | python -m venv ./venv/ 155 | 156 | # activate that copy of python (now 'which python' points to your new virtual copy) 157 | source ./venv/bin/activate 158 | 159 | # re-run whatever 'just' command you just tried to run, for example 160 | just install 161 | ``` 162 | 163 | -- Sincerely, The venv police 👮 🐍 164 | """)) 165 | 166 | sys.exit(1) 167 | 168 | print("[mlops-club] ✅ Virtual environment is active") 169 | 170 | # print the AWS account ID of the current AWS_PROFILE to stdout 171 | get-aws-account-id: 172 | #!/usr/bin/env python3 173 | import json 174 | import subprocess 175 | 176 | args = ["aws", "sts", "get-caller-identity", "--profile", "{{AWS_PROFILE}}"] 177 | proc = subprocess.run(args, capture_output=True) 178 | 179 | aws_cli_response = json.loads(proc.stdout) 180 | print(aws_cli_response["Account"]) 181 | 182 | # run quality checks and autoformatters against your code 183 | lint: require-venv 184 | pre-commit run --all-files 185 | 186 | publish-python-package-test: 187 | twine upload \ 188 | --repository-url "https://test.pypi.org/legacy/" \ 189 | --username "${TEST_PYPI__TWINE_USERNAME}" \ 190 | --password "${TEST_PYPI__TWINE_PASSWORD}" \ 191 | --verbose \ 192 | dist/* 193 | 194 | publish-python-package-prod: 195 | twine upload \ 196 | --repository-url "https://upload.pypi.org/legacy/" \ 197 | --username "${TWINE_USERNAME}" \ 198 | --password "${TWINE_PASSWORD}" \ 199 | --verbose \ 200 | dist/* 201 | 202 | clean: 203 | rm -rf ./dist/ **/dist/ || echo "no matches found for **/dist/" 204 | rm -rf .projen/ **/.projen/ || echo "no matches found for **/.projen/" 205 | rm -rf ./build/ **/build/ || echo "no matches found for **/build/" 206 | rm -rf ./build_/ **/build_/ || echo "no matches found for **/build/" 207 | rm -rf ./cdk.out/ **/cdk.out/ || echo "no matches found for **/cdk.out/" 208 | rm -rf ./.DS_Store/ **/.DS_Store || echo "no matches found for **/.DS_Store" 209 | rm -rf ./.mypy_cache/ **/.mypy_cache || echo "no matches found for **/.mypy_cache" 210 | rm -rf ./.pytest_cache/ **/.pytest_cache || echo "no matches found for **/*.pytest_cache" 211 | rm -rf ./test/ **/test || echo "no matches found for **/test" 212 | rm -rf ./.coverage/ **/.coverage || echo "no matches found for **/.coverage" 213 | rm -rf ./.ipynb_checkpoints/ **/.ipynb_checkpoints || echo "no matches found for **/.ipynb_checkpoints" 214 | rm -rf ./.pyc/ **/*.pyc || echo "no matches found for **/*.pyc" 215 | rm -rf ./__pycache__/ **__pycache__ || echo "no matches found for **/__pycache__" 216 | rm -rf ./*.egg-info/ *.egg-info || echo "no matches found for **/*.egg-info" 217 | rm cdk.context.json **/*cdk.context.json || echo "no matches found for cdk.context.json" 218 | 219 | find . -type d -name "*.egg-info" -exec rm -rf {} + 220 | find . -type d -name "__pycache__" -exec rm -rf {} + 221 | find . -type d -name "htmlcov" -exec rm -rf {} + 222 | find . -type d -name "test-reports" -exec rm -rf {} + 223 | find . -type f -name "coverage.xml" -exec rm -rf {} + 224 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | Note from the awscdk-minecraft authors: we see Mozilla Public License 2.0 as a 5 | good license because it promotes open-source and allows commercial use 6 | because it is non-viral. 7 | 8 | 1. Definitions 9 | -------------- 10 | 11 | 1.1. "Contributor" 12 | means each individual or legal entity that creates, contributes to 13 | the creation of, or owns Covered Software. 14 | 15 | 1.2. "Contributor Version" 16 | means the combination of the Contributions of others (if any) used 17 | by a Contributor and that particular Contributor's Contribution. 18 | 19 | 1.3. "Contribution" 20 | means Covered Software of a particular Contributor. 21 | 22 | 1.4. "Covered Software" 23 | means Source Code Form to which the initial Contributor has attached 24 | the notice in Exhibit A, the Executable Form of such Source Code 25 | Form, and Modifications of such Source Code Form, in each case 26 | including portions thereof. 27 | 28 | 1.5. "Incompatible With Secondary Licenses" 29 | means 30 | 31 | (a) that the initial Contributor has attached the notice described 32 | in Exhibit B to the Covered Software; or 33 | 34 | (b) that the Covered Software was made available under the terms of 35 | version 1.1 or earlier of the License, but not also under the 36 | terms of a Secondary License. 37 | 38 | 1.6. "Executable Form" 39 | means any form of the work other than Source Code Form. 40 | 41 | 1.7. "Larger Work" 42 | means a work that combines Covered Software with other material, in 43 | a separate file or files, that is not Covered Software. 44 | 45 | 1.8. "License" 46 | means this document. 47 | 48 | 1.9. "Licensable" 49 | means having the right to grant, to the maximum extent possible, 50 | whether at the time of the initial grant or subsequently, any and 51 | all of the rights conveyed by this License. 52 | 53 | 1.10. "Modifications" 54 | means any of the following: 55 | 56 | (a) any file in Source Code Form that results from an addition to, 57 | deletion from, or modification of the contents of Covered 58 | Software; or 59 | 60 | (b) any new file in Source Code Form that contains any Covered 61 | Software. 62 | 63 | 1.11. "Patent Claims" of a Contributor 64 | means any patent claim(s), including without limitation, method, 65 | process, and apparatus claims, in any patent Licensable by such 66 | Contributor that would be infringed, but for the grant of the 67 | License, by the making, using, selling, offering for sale, having 68 | made, import, or transfer of either its Contributions or its 69 | Contributor Version. 70 | 71 | 1.12. "Secondary License" 72 | means either the GNU General Public License, Version 2.0, the GNU 73 | Lesser General Public License, Version 2.1, the GNU Affero General 74 | Public License, Version 3.0, or any later versions of those 75 | licenses. 76 | 77 | 1.13. "Source Code Form" 78 | means the form of the work preferred for making modifications. 79 | 80 | 1.14. "You" (or "Your") 81 | means an individual or a legal entity exercising rights under this 82 | License. For legal entities, "You" includes any entity that 83 | controls, is controlled by, or is under common control with You. For 84 | purposes of this definition, "control" means (a) the power, direct 85 | or indirect, to cause the direction or management of such entity, 86 | whether by contract or otherwise, or (b) ownership of more than 87 | fifty percent (50%) of the outstanding shares or beneficial 88 | ownership of such entity. 89 | 90 | 2. License Grants and Conditions 91 | -------------------------------- 92 | 93 | 2.1. Grants 94 | 95 | Each Contributor hereby grants You a world-wide, royalty-free, 96 | non-exclusive license: 97 | 98 | (a) under intellectual property rights (other than patent or trademark) 99 | Licensable by such Contributor to use, reproduce, make available, 100 | modify, display, perform, distribute, and otherwise exploit its 101 | Contributions, either on an unmodified basis, with Modifications, or 102 | as part of a Larger Work; and 103 | 104 | (b) under Patent Claims of such Contributor to make, use, sell, offer 105 | for sale, have made, import, and otherwise transfer either its 106 | Contributions or its Contributor Version. 107 | 108 | 2.2. Effective Date 109 | 110 | The licenses granted in Section 2.1 with respect to any Contribution 111 | become effective for each Contribution on the date the Contributor first 112 | distributes such Contribution. 113 | 114 | 2.3. Limitations on Grant Scope 115 | 116 | The licenses granted in this Section 2 are the only rights granted under 117 | this License. No additional rights or licenses will be implied from the 118 | distribution or licensing of Covered Software under this License. 119 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 120 | Contributor: 121 | 122 | (a) for any code that a Contributor has removed from Covered Software; 123 | or 124 | 125 | (b) for infringements caused by: (i) Your and any other third party's 126 | modifications of Covered Software, or (ii) the combination of its 127 | Contributions with other software (except as part of its Contributor 128 | Version); or 129 | 130 | (c) under Patent Claims infringed by Covered Software in the absence of 131 | its Contributions. 132 | 133 | This License does not grant any rights in the trademarks, service marks, 134 | or logos of any Contributor (except as may be necessary to comply with 135 | the notice requirements in Section 3.4). 136 | 137 | 2.4. Subsequent Licenses 138 | 139 | No Contributor makes additional grants as a result of Your choice to 140 | distribute the Covered Software under a subsequent version of this 141 | License (see Section 10.2) or under the terms of a Secondary License (if 142 | permitted under the terms of Section 3.3). 143 | 144 | 2.5. Representation 145 | 146 | Each Contributor represents that the Contributor believes its 147 | Contributions are its original creation(s) or it has sufficient rights 148 | to grant the rights to its Contributions conveyed by this License. 149 | 150 | 2.6. Fair Use 151 | 152 | This License is not intended to limit any rights You have under 153 | applicable copyright doctrines of fair use, fair dealing, or other 154 | equivalents. 155 | 156 | 2.7. Conditions 157 | 158 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 159 | in Section 2.1. 160 | 161 | 3. Responsibilities 162 | ------------------- 163 | 164 | 3.1. Distribution of Source Form 165 | 166 | All distribution of Covered Software in Source Code Form, including any 167 | Modifications that You create or to which You contribute, must be under 168 | the terms of this License. You must inform recipients that the Source 169 | Code Form of the Covered Software is governed by the terms of this 170 | License, and how they can obtain a copy of this License. You may not 171 | attempt to alter or restrict the recipients' rights in the Source Code 172 | Form. 173 | 174 | 3.2. Distribution of Executable Form 175 | 176 | If You distribute Covered Software in Executable Form then: 177 | 178 | (a) such Covered Software must also be made available in Source Code 179 | Form, as described in Section 3.1, and You must inform recipients of 180 | the Executable Form how they can obtain a copy of such Source Code 181 | Form by reasonable means in a timely manner, at a charge no more 182 | than the cost of distribution to the recipient; and 183 | 184 | (b) You may distribute such Executable Form under the terms of this 185 | License, or sublicense it under different terms, provided that the 186 | license for the Executable Form does not attempt to limit or alter 187 | the recipients' rights in the Source Code Form under this License. 188 | 189 | 3.3. Distribution of a Larger Work 190 | 191 | You may create and distribute a Larger Work under terms of Your choice, 192 | provided that You also comply with the requirements of this License for 193 | the Covered Software. If the Larger Work is a combination of Covered 194 | Software with a work governed by one or more Secondary Licenses, and the 195 | Covered Software is not Incompatible With Secondary Licenses, this 196 | License permits You to additionally distribute such Covered Software 197 | under the terms of such Secondary License(s), so that the recipient of 198 | the Larger Work may, at their option, further distribute the Covered 199 | Software under the terms of either this License or such Secondary 200 | License(s). 201 | 202 | 3.4. Notices 203 | 204 | You may not remove or alter the substance of any license notices 205 | (including copyright notices, patent notices, disclaimers of warranty, 206 | or limitations of liability) contained within the Source Code Form of 207 | the Covered Software, except that You may alter any license notices to 208 | the extent required to remedy known factual inaccuracies. 209 | 210 | 3.5. Application of Additional Terms 211 | 212 | You may choose to offer, and to charge a fee for, warranty, support, 213 | indemnity or liability obligations to one or more recipients of Covered 214 | Software. However, You may do so only on Your own behalf, and not on 215 | behalf of any Contributor. You must make it absolutely clear that any 216 | such warranty, support, indemnity, or liability obligation is offered by 217 | You alone, and You hereby agree to indemnify every Contributor for any 218 | liability incurred by such Contributor as a result of warranty, support, 219 | indemnity or liability terms You offer. You may include additional 220 | disclaimers of warranty and limitations of liability specific to any 221 | jurisdiction. 222 | 223 | 4. Inability to Comply Due to Statute or Regulation 224 | --------------------------------------------------- 225 | 226 | If it is impossible for You to comply with any of the terms of this 227 | License with respect to some or all of the Covered Software due to 228 | statute, judicial order, or regulation then You must: (a) comply with 229 | the terms of this License to the maximum extent possible; and (b) 230 | describe the limitations and the code they affect. Such description must 231 | be placed in a text file included with all distributions of the Covered 232 | Software under this License. Except to the extent prohibited by statute 233 | or regulation, such description must be sufficiently detailed for a 234 | recipient of ordinary skill to be able to understand it. 235 | 236 | 5. Termination 237 | -------------- 238 | 239 | 5.1. The rights granted under this License will terminate automatically 240 | if You fail to comply with any of its terms. However, if You become 241 | compliant, then the rights granted under this License from a particular 242 | Contributor are reinstated (a) provisionally, unless and until such 243 | Contributor explicitly and finally terminates Your grants, and (b) on an 244 | ongoing basis, if such Contributor fails to notify You of the 245 | non-compliance by some reasonable means prior to 60 days after You have 246 | come back into compliance. Moreover, Your grants from a particular 247 | Contributor are reinstated on an ongoing basis if such Contributor 248 | notifies You of the non-compliance by some reasonable means, this is the 249 | first time You have received notice of non-compliance with this License 250 | from such Contributor, and You become compliant prior to 30 days after 251 | Your receipt of the notice. 252 | 253 | 5.2. If You initiate litigation against any entity by asserting a patent 254 | infringement claim (excluding declaratory judgment actions, 255 | counter-claims, and cross-claims) alleging that a Contributor Version 256 | directly or indirectly infringes any patent, then the rights granted to 257 | You by any and all Contributors for the Covered Software under Section 258 | 2.1 of this License shall terminate. 259 | 260 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 261 | end user license agreements (excluding distributors and resellers) which 262 | have been validly granted by You or Your distributors under this License 263 | prior to termination shall survive termination. 264 | 265 | ************************************************************************ 266 | * * 267 | * 6. Disclaimer of Warranty * 268 | * ------------------------- * 269 | * * 270 | * Covered Software is provided under this License on an "as is" * 271 | * basis, without warranty of any kind, either expressed, implied, or * 272 | * statutory, including, without limitation, warranties that the * 273 | * Covered Software is free of defects, merchantable, fit for a * 274 | * particular purpose or non-infringing. The entire risk as to the * 275 | * quality and performance of the Covered Software is with You. * 276 | * Should any Covered Software prove defective in any respect, You * 277 | * (not any Contributor) assume the cost of any necessary servicing, * 278 | * repair, or correction. This disclaimer of warranty constitutes an * 279 | * essential part of this License. No use of any Covered Software is * 280 | * authorized under this License except under this disclaimer. * 281 | * * 282 | ************************************************************************ 283 | 284 | ************************************************************************ 285 | * * 286 | * 7. Limitation of Liability * 287 | * -------------------------- * 288 | * * 289 | * Under no circumstances and under no legal theory, whether tort * 290 | * (including negligence), contract, or otherwise, shall any * 291 | * Contributor, or anyone who distributes Covered Software as * 292 | * permitted above, be liable to You for any direct, indirect, * 293 | * special, incidental, or consequential damages of any character * 294 | * including, without limitation, damages for lost profits, loss of * 295 | * goodwill, work stoppage, computer failure or malfunction, or any * 296 | * and all other commercial damages or losses, even if such party * 297 | * shall have been informed of the possibility of such damages. This * 298 | * limitation of liability shall not apply to liability for death or * 299 | * personal injury resulting from such party's negligence to the * 300 | * extent applicable law prohibits such limitation. Some * 301 | * jurisdictions do not allow the exclusion or limitation of * 302 | * incidental or consequential damages, so this exclusion and * 303 | * limitation may not apply to You. * 304 | * * 305 | ************************************************************************ 306 | 307 | 8. Litigation 308 | ------------- 309 | 310 | Any litigation relating to this License may be brought only in the 311 | courts of a jurisdiction where the defendant maintains its principal 312 | place of business and such litigation shall be governed by laws of that 313 | jurisdiction, without reference to its conflict-of-law provisions. 314 | Nothing in this Section shall prevent a party's ability to bring 315 | cross-claims or counter-claims. 316 | 317 | 9. Miscellaneous 318 | ---------------- 319 | 320 | This License represents the complete agreement concerning the subject 321 | matter hereof. If any provision of this License is held to be 322 | unenforceable, such provision shall be reformed only to the extent 323 | necessary to make it enforceable. Any law or regulation which provides 324 | that the language of a contract shall be construed against the drafter 325 | shall not be used to construe this License against a Contributor. 326 | 327 | 10. Versions of the License 328 | --------------------------- 329 | 330 | 10.1. New Versions 331 | 332 | Mozilla Foundation is the license steward. Except as provided in Section 333 | 10.3, no one other than the license steward has the right to modify or 334 | publish new versions of this License. Each version will be given a 335 | distinguishing version number. 336 | 337 | 10.2. Effect of New Versions 338 | 339 | You may distribute the Covered Software under the terms of the version 340 | of the License under which You originally received the Covered Software, 341 | or under the terms of any subsequent version published by the license 342 | steward. 343 | 344 | 10.3. Modified Versions 345 | 346 | If you create software not governed by this License, and you want to 347 | create a new license for such software, you may create and use a 348 | modified version of this License if you rename the license and remove 349 | any references to the name of the license steward (except to note that 350 | such modified license differs from this License). 351 | 352 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 353 | Licenses 354 | 355 | If You choose to distribute Source Code Form that is Incompatible With 356 | Secondary Licenses under the terms of this version of the License, the 357 | notice described in Exhibit B of this License must be attached. 358 | 359 | Exhibit A - Source Code Form License Notice 360 | ------------------------------------------- 361 | 362 | This Source Code Form is subject to the terms of the Mozilla Public 363 | License, v. 2.0. If a copy of the MPL was not distributed with this 364 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 365 | 366 | If it is not possible or desirable to put the notice in a particular 367 | file, then You may include the notice in a location (such as a LICENSE 368 | file in a relevant directory) where a recipient would be likely to look 369 | for such a notice. 370 | 371 | You may add additional accurate notices of copyright ownership. 372 | 373 | Exhibit B - "Incompatible With Secondary Licenses" Notice 374 | --------------------------------------------------------- 375 | 376 | This Source Code Form is "Incompatible With Secondary Licenses", as 377 | defined by the Mozilla Public License, v. 2.0. 378 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-fastapi 2 | 3 | ![](./docs/openapi.png) 4 | 5 | References: 6 | 7 | 1. AWS-themed GitHub Pages marketing page: https://aws-otel.github.io/ 8 | 2. AWS Lambda Python setup: https://aws-otel.github.io/docs/getting-started/lambda/lambda-python 9 | 3. OpenTelemetry Python autoinstrumentation [PyPI page](https://pypi.org/project/opentelemetry-instrumentation/) 10 | 4. [Sample AWS ADOT-instrumented Flask API app](https://github.com/aws-observability/aws-otel-python/tree/main/integration-test-apps/auto-instrumentation/flask) 11 | 12 | ## Notes 13 | 14 | ### License 15 | 16 | Talk about the common licenses. 17 | 18 | Risk: people could take your code without crediting you and profit from it. Or potentially claim that they authored it. 19 | 20 | Desire: You may want to allow certain types of access, maybe requesting attribution. 21 | 22 | When you are writing commercial software, licenses matter extra, you don't want to get your company in trouble for stealing code. Could talk about the controversy of GitHub Copilot. 23 | 24 | ### CI and Code Review 25 | 26 | - Define Code Review, integration, continuous integration, Peer review, Pull reqeust 27 | - Human attention is extremely valuable! Don't waste it on anything a machine can review for you. This point should be extra clear because by this time, we should have discussed how the PR process slows teams down in the first place, and pair programming is the ideal. 28 | 29 | - Maybe we should show the DevOps infinity visualization 30 | 31 | - It'd be great to cover different badges as we go: 32 | - shields.io 33 | - build passing / failing (GitHub actions), azure, circle, etc. have this too 34 | - pre-commit has a service 35 | - test coverage; coverage umbrella vendor. Talk about how GitHub apps vend: usually make it free to use and charge for commercial/private software 36 | 37 | - It could be good to talk about common "bots" like: 38 | - dependabot 39 | - all contributors 40 | 41 | - We should have a section on security scanning. Discuss the types of vulerabilities: supply chain, sshgit, exploits, etc. 42 | - Discuss how to pick dependencies: the snyk package index 43 | - Some companies only allow you to use a pre-approved list of packages, or even pre-approved versions, which they may enforce by vending to an internal PyPI server 44 | - Private PyPI server 45 | 46 | ### Poetry 47 | 48 | Very complete [pyproject.toml](https://github.com/rstcheck/rstcheck/blob/main/pyproject.toml) file 49 | 50 | Also the `rich` 51 | 52 | #### Python packaging history 53 | 54 | The history of Python packaging is long and complicated. And python to an early python three, the python standard library had a Library called distutils. The point of this library wants to provide a standard way of sharing python code. Python packages generally need a few things. They need a way to specify dependencies, they need a way to specify versions, and it's nice if they can specify metadata like who the author is and what get out the repository is associated with the cove base. The library also provided a way to include assets such as binaries, images, Jason files, etc. 55 | 56 | The problem with this details is that it was very hard to use. The distribution utilities library was part of the python standard library, but it was a pain to use. So the community responded with an abstraction over distribution utilities call to set up tools. Set up tools was never made part of the python standard library. However, set up tools was so easy to use became much more popular than a built in distribution utilities library. Python maintainer saw that set up tools provided a much better packaging experience for Python users than just details did. So, although set up tools was not part of the official python standard library, I Python maintainers declared that set up tools was the officially recommended way of packaging python code into a shareable form. Today, if you go to the official Python documentation, 57 | you will find packaging instructions using set up tools to create packages from Python code. 58 | 59 | Despite set up tools being easier to use than distribution utilities, many still found it very difficult to use. Beginners and professionals alike found that difficult troubleshooting was almost always necessary whenever they were adding intermediate to advanced packaging features to the package. For example, the manner in which you add binaries to python package using set up tools is by writing a file calle manifest dot in. In addition to writing this file, you must also add some special configuration. This is an notoriously difficult process to get right. Mini project packaging templates are available online do you help beginners get set up tools working correctly. While these templates work, they are difficult to modify or fully understand, because of how complicated they are. 60 | 61 | Python maintainers acknowledge that set up tools was not satisfying the needs of certain python users. There was also a interesting problem faced by package authors that had to do with running isolated builds to separate package dependencies from build time dependencies. In response to both of these issues, the Python maintainers authored a PEP proposal from which a tool called build was created. Since set up tools was a third-party packaging tool, in theory nothing should stop other groups from creating other third-party packaging tools that did the same job. The PEP proposal for build defined a standard for building build back ends which could be used to build python distributions such as source distributions and wheels. Command line tool called build emerged from this. The build command line tool is capable of building any python package using a valid build back end. Some popular build Backends include poetry, Hatch, and flit. Of those three options, poetry is by far the most popular. 62 | 63 | `pipx` and `fastapi` use `hatch`. Never seen a project using `flit`. 64 | 65 | [PEP 621](https://peps.python.org/pep-0621/#example) defines a standard 66 | way of putting `setup.cfg` info directly into `pyproject.toml`. It doesn't look like 67 | `poetry` uses this format... does the `hatchling` engine? [Setuptools has adopted 68 | the format](https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html), so we could use this at work. 69 | 70 | The Python Packaging Authority (PyPA) is a working group that maintains a core set of software projects used in Python packaging. 71 | The PyPA publishes the P[ython Packaging User Guide](https://packaging.python.org/en/latest/), which is the authoritative resource on how to package, publish, and install Python projects using current tools. 72 | 73 | #### `.ini` and `.toml` 74 | 75 | `.ini` is awful! But the stdlib had `ConfigParser`. As of [PEP 680](https://peps.python.org/pep-0680/) in Jan 2022, `tomllib` is part of the standard library. This goes nicely with `PEP 621` which defined the standard packaging metadata format in `pyproject.toml`. 76 | 77 | 78 | #### Building and installing poetry packages 79 | 80 | Build a package: 81 | 82 | 1. fill out `pyproject.toml`; set the `build-backend` 83 | 2. `pipx --spec build --wheel --sdist ./path/to/package` 84 | 85 | You can also just install the package as normal with: 86 | 87 | 1. `pip install ./path/to/package/[some,extras]` 88 | 89 | So cool! The end users are completely unaffected by the fact that our build backend might be poetry. 90 | 91 | ### Canary deployments 92 | 93 | AWS has a [canary deployment with CDK workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/5195ab7c-5ded-4ee2-a1c5-775300717f42/en-US). 94 | 95 | ### Metrics 96 | 97 | - We can log metrics to the console. We can also send metrics to the otel collector. The otel collector then 98 | logs the metrics to the console, but in EMF format (since that's what the awsemf exporter does). 99 | 100 | 101 | Code emits metric -> otlp collector -> prometheus, console, cloudwatch log stream in EMF format 102 | Code emits trace -> otlp collector -> x-ray, console 103 | Code emits log -> otlp collector -> cloudwatch log group 104 | 105 | The use should be walked through how the OTLP collector works. It uses 106 | 107 | - receivers 108 | - processors 109 | - exporters 110 | - services 111 | - extensions 112 | 113 | ^^^ Great (official) [docs on all this](https://opentelemetry.io/docs/collector/configuration/) 114 | 115 | - It can support multiple backends for each 116 | - metrics: prometheus 117 | - traces: x-ray, jaeger, zipkin, tempo 118 | - logs: cloudwatch, splunk, elastic, kafka, file, loki. 119 | - Logs also use the docker log driver. Where does that fit in? Does a OTEL log driver 120 | have to batch logs at the service? Maybe logs don't need to be send to the OTLP collector. 121 | As long as the logs have the trace IDs, they can be correlated in the final destination. 122 | - If EMF format is used for metrics, maybe we don't have to send metrics to the OTLP collecor... 123 | except, if we use the opentelemetry SDK, our metrics are probably in OTLP format (somehow), not EMF, 124 | so they would need to be sent to the OTLP collector to then be "exported" in the right format 125 | 126 | Lambda extensions 127 | - This [ADOT Lambda design spec](https://github.com/open-telemetry/opentelemetry-lambda/blob/main/docs/design_proposal.md) shows 128 | that the ADOT lambda extension is literally the OTLP collector running as a separate process inside of the lambda container. 129 | So your function executes alongside it and proactively sends metrics to it. 130 | - This is [the blogpost announcing Lambda extensions](https://aws.amazon.com/blogs/aws/getting-started-with-using-your-favorite-operational-tools-on-aws-lambda-extensions-are-now-generally-available/). NewRelic, DataDog, and ADOT are all here. 131 | - Lambda extensions make your functions more expensive. The [Lambda pricing page](https://aws.amazon.com/lambda/pricing/) has this callout: 132 | 133 | > Duration charges apply to code that runs in the handler of a function as well as initialization code that is declared outside of the handler. For Lambda functions with AWS Lambda Extensions, duration also includes the time it takes for code in the last running extension to finish executing during shutdown phase. For more details, see the Lambda Programming Model documentation. 134 | 135 | So it's not as though the extension is doubling the cost of the function. It may it some cases. You only pay for time 136 | it takes for the extension to shut down, since the lambda runtime can't stop until your code and all extensions have stopped. 137 | Hopefully that's not too long. 138 | 139 | ### Not covered 140 | 141 | If you want to learn more about: 142 | 143 | - Docker 144 | - FastAPI 145 | - git/GitHub/GitHub Actions 146 | - AWS 147 | - CloudFormation 148 | - Linux/bash 149 | 150 | Take additional courses on them and/or *build something*. This course will explain the way we use these things 151 | enough to give context. A beginner should be able to follow along line-by-line and understand the role each tool 152 | plays, but this course won't be a deep dive on those topics. 153 | 154 | Expect this course to deep dive on: 155 | 156 | - Concepts of deploying and monitoring production software 157 | - 158 | 159 | ### Course Announcement 160 | 161 | Potential name: "Foundations for MLOps on AWS: deploying and managing software in production" 162 | 163 | Target audience: people who know Python and would like to learn how to "ship" production software using AWS. 164 | 165 | 166 | 167 | We'll use the free-tier services here to 168 | 169 | 170 | 171 | - write a production-ready REST API with FastAPI 172 | 173 | - test the code with unit testing, and the scalability with load testing 174 | 175 | - deploy it with infrastructure as code (AWS CDK) as we would in a production setting 176 | 177 | - monitor it with logs, traces, metrics, dashboards, and alerts 178 | 179 | - update it 180 | 181 | - cheaply start small (with AWS Lambda), cheaply scale up (with Docker containers) 182 | 183 | - continuous integration, continuous delivery, and continuous deployment using GitHub Actions 184 | 185 | 186 | 187 | ### AWS Free Tier 188 | 189 | Table of these images with labels of AWS services. 2 images in each row. 190 | 191 | | | | 192 | | ----------------------------------------------------- | ------------------------------------------------------- | 193 | | ![](./docs/free-tier/cloudtrail.png)
CloudTrail | ![](./docs/free-tier/cloudwatch.png)
CloudWatch | 194 | | ![](./docs/free-tier/cloudfront.png)
CloudFront | ![](./docs/free-tier/lambda.png)
Lambda | 195 | | ![](./docs/free-tier/x-ray.png)
X-Ray | ![](./docs/free-tier/s3.png)
S3 | 196 | | ![](./docs/free-tier/cognito.png)
Cognito | ![](./docs/free-tier/api-gateway.png)
API Gateway | 197 | 198 | 199 | 200 | 201 | ### IaC advantages 202 | 203 | - Free tier: delete your account and redeploy your whole app in a new one 204 | - renewed 15-30 day free tier 205 | - renewed 12-month free tier 206 | - Deploy apps in an identical way: hook up all your REST APIs with monitoring, alerts, cost saving architecture, security, etc. 207 | - Easy consistent tagging strategy, great for tracking costs, which developer created something, which app each resource is part of, etc. 208 | - Automated deploys: continuous deployment 209 | - Complete teardown: save money by cleaning up every resource associated with an entire, complex app 210 | - State management: 10 resources could have (10 choose 0) + (10 choose 1) + ... + (10 choose 10) possible created/not-created states 211 | not to mention the individual attributes 212 | - Your account doesn't become a mess, especially if you are sharing it with other developers. Story of how BEN's 213 | `ben-ai-development` account became a production account. Story of Steve deleting his personal S3 bucket which 214 | turned out to have production data in it by the time he deleted it. 215 | - Abstraction: deploy apps with constructs like `ApplicationLoadBalancedFargateService` and `s3.Bucket(auto_delete=True)` 216 | which creates a lambda function. Talk about those lambda functions. Quickly create complex infrastructures you would 217 | otherwise spend weeks designing. Learn cloud architecture years faster than it used to take senior engineers. 218 | 219 | ### API Gateway 220 | 221 | I got these metrics when I set `metrics_enabled=True`. 222 | 223 | ![](./docs/api-gateway-metrics.png) 224 | 225 | And this when I set `tracing_enabled=True`. Before setting this to `True`, we saw the same visualization, but 226 | the API Gateway component was missing. 227 | 228 | ![](./docs/api-gateway-trace.png) 229 | 230 | ```python 231 | self._api = apigw.RestApi( 232 | self, 233 | f"-RestApi", 234 | deploy_options=apigw.StageOptions( 235 | stage_name=DEFAULT_STAGE_NAME, 236 | metrics_enabled=True, 237 | tracing_enabled=True, 238 | description="Production stage", 239 | throttling_burst_limit=10, 240 | throttling_rate_limit=2, 241 | ), 242 | description="Serverless FastAPI", 243 | ) 244 | ``` 245 | 246 | ### Instrumentation 247 | 248 | Good resource: 249 | 250 | ### `uvicorn` problem 251 | 252 | `opentelemetry-instrument -- uvicorn ...` fails to autoinstrument our app. 253 | 254 | [This GitHub issue](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/385) says that it's an issue with how uvicorn starts workers. Uvicorn starts new processes. The uvicorn main 255 | process gets instrumented, but the new child processes containing our FastAPI application code don't end up getting 256 | instrumented. 257 | 258 | I verified that I could make instrumenting work by adding the autoinstrumentation lines of code for 259 | logging directly in our code files. So manual instrumentation works to an extent with `uvicorn`. To get around 260 | this issue, I switched the `Dockerfile` to use `gunicorn` instead. That fixed the issue. 261 | 262 | This should be fine, we use `gunicorn` in production. Also, `gunicorn` won't even be used for production in this case, 263 | as we'll be using AWS Lambda. As long as autoinstrumentation works in AWS Lambda as well, we can be confident that 264 | we can deploy our app in Docker with `gunicorn` or on AWS Lambda with the OTel AWS distro without having to modify 265 | our code. 266 | 267 | ### Structured logging 268 | 269 | AWS likes it when you use `json.dumps()` on python dicts when inserting them in log statements. 270 | 271 | You might to a statement like 272 | 273 | ```python 274 | import json 275 | logger.info("This is a log statement with a jsonified dict %s", json.dumps({"key": "value"})) 276 | ``` 277 | 278 | The key-value pairs in the dict will become structured and searchable via CloudWatch logs. 279 | The builtin AWS log fields are usually `@message` and the others you can see in this screenshot: 280 | 281 | ![xray](./docs/correlated-logs.png) 282 | 283 | ### Custom choices 284 | 285 | Any custom choices you make for your app are BAD! 286 | 287 | - Logging format 288 | - Generating request ID, what to call that header 289 | 290 | You want as many apps in your group/company to use the same standards as possible. 291 | 292 | ### Course presentation 293 | 294 | MLOps is a combination of Software Development, DevOps, ML Engineering, and Data Engineering. 295 | 296 | This course will give lots of good exposure to Software Development and DevOps, and attempt 297 | to include ML Engineering examples (like when you need to keep track of RAM, say ways 298 | a ML service could crash due to the problems typical services run into). 299 | 300 | Show a heirarchy of needs? 301 | 302 | - Code 303 | - Deployment 304 | - Training, re-training, and special deployment steps for MLOps (not covered here) 305 | - Monitoring afterward 306 | - ML-specific Monitoring (not covered here) 307 | 308 | Talk about logs, traces, metrics, dashboards, alerts. 309 | 310 | ### Badges 311 | 312 | Could do a quick tangent on https://shields.io/ when we make the FastAPI docs description. 313 | 314 | ### API Gateway 315 | 316 | Example `event` passed from the API Gateway to the lambda: 317 | 318 | ```python 319 | { 320 | "resource": "/{proxy+}", 321 | "path": "/openapi.json", 322 | "httpMethod": "GET", 323 | "headers": { 324 | "Accept": "application/json,*/*", 325 | "Accept-Encoding": "gzip, deflate, br", 326 | "Accept-Language": "en-US,en;q=0.8", 327 | "CloudFront-Forwarded-Proto": "https", 328 | "CloudFront-Is-Desktop-Viewer": "true", 329 | "CloudFront-Is-Mobile-Viewer": "false", 330 | "CloudFront-Is-SmartTV-Viewer": "false", 331 | "CloudFront-Is-Tablet-Viewer": "false", 332 | "CloudFront-Viewer-ASN": "21928", 333 | "CloudFront-Viewer-Country": "US", 334 | "Host": "n8484inss8.execute-api.us-west-2.amazonaws.com", 335 | "Referer": "https://n8484inss8.execute-api.us-west-2.amazonaws.com/prod/", 336 | "sec-fetch-dest": "empty", 337 | "sec-fetch-mode": "cors", 338 | "sec-fetch-site": "same-origin", 339 | "sec-gpc": "1", 340 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", 341 | "Via": "2.0 ec99de6a8df96b4e008b942ab98e6594.cloudfront.net (CloudFront)", 342 | "X-Amz-Cf-Id": "kQzO9p3DMD3ncxkO4iPqRzen6HhIk8Y-oAUMgnwL06XZ1MLQMkBMWA==", 343 | "X-Amzn-Trace-Id": "Root=1-63dd9630-1f7ec6c948e660f220ef3c92", 344 | "X-Forwarded-For": "172.59.153.63, 64.252.130.90", 345 | "X-Forwarded-Port": "443", 346 | "X-Forwarded-Proto": "https", 347 | }, 348 | "multiValueHeaders": { 349 | "Accept": ["application/json,*/*"], 350 | "Accept-Encoding": ["gzip, deflate, br"], 351 | "Accept-Language": ["en-US,en;q=0.8"], 352 | "CloudFront-Forwarded-Proto": ["https"], 353 | "CloudFront-Is-Desktop-Viewer": ["true"], 354 | "CloudFront-Is-Mobile-Viewer": ["false"], 355 | "CloudFront-Is-SmartTV-Viewer": ["false"], 356 | "CloudFront-Is-Tablet-Viewer": ["false"], 357 | "CloudFront-Viewer-ASN": ["21928"], 358 | "CloudFront-Viewer-Country": ["US"], 359 | "Host": ["n8484inss8.execute-api.us-west-2.amazonaws.com"], 360 | "Referer": ["https://n8484inss8.execute-api.us-west-2.amazonaws.com/prod/"], 361 | "sec-fetch-dest": ["empty"], 362 | "sec-fetch-mode": ["cors"], 363 | "sec-fetch-site": ["same-origin"], 364 | "sec-gpc": ["1"], 365 | "User-Agent": [ 366 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" 367 | ], 368 | "Via": ["2.0 ec99de6a8df96b4e008b942ab98e6594.cloudfront.net (CloudFront)"], 369 | "X-Amz-Cf-Id": ["kQzO9p3DMD3ncxkO4iPqRzen6HhIk8Y-oAUMgnwL06XZ1MLQMkBMWA=="], 370 | "X-Amzn-Trace-Id": ["Root=1-63dd9630-1f7ec6c948e660f220ef3c92"], 371 | "X-Forwarded-For": ["172.59.153.63, 64.252.130.90"], 372 | "X-Forwarded-Port": ["443"], 373 | "X-Forwarded-Proto": ["https"], 374 | }, 375 | "queryStringParameters": None, 376 | "multiValueQueryStringParameters": None, 377 | "pathParameters": {"proxy": "openapi.json"}, 378 | "stageVariables": None, 379 | "requestContext": { 380 | "resourceId": "rpy2e8", 381 | "resourcePath": "/{proxy+}", 382 | "httpMethod": "GET", 383 | "extendedRequestId": "fyRnmEIvPHcFmIg=", 384 | "requestTime": "03/Feb/2023:23:18:08 +0000", 385 | "path": "/prod/openapi.json", 386 | "accountId": "643884464387", 387 | "protocol": "HTTP/1.1", 388 | "stage": "prod", 389 | "domainPrefix": "n8484inss8", 390 | "requestTimeEpoch": 1675466288419, 391 | "requestId": "2bfe1913-eea8-48de-b643-166b53428586", 392 | "identity": { 393 | "cognitoIdentityPoolId": None, 394 | "accountId": None, 395 | "cognitoIdentityId": None, 396 | "caller": None, 397 | "sourceIp": "172.59.153.63", 398 | "principalOrgId": None, 399 | "accessKey": None, 400 | "cognitoAuthenticationType": None, 401 | "cognitoAuthenticationProvider": None, 402 | "userArn": None, 403 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", 404 | "user": None, 405 | }, 406 | "domainName": "n8484inss8.execute-api.us-west-2.amazonaws.com", 407 | "apiId": "n8484inss8", 408 | }, 409 | "body": None, 410 | "isBase64Encoded": False, 411 | } 412 | 413 | ``` -------------------------------------------------------------------------------- /docs/Untitled Diagram.drawio: -------------------------------------------------------------------------------- 1 | UzV2zq1wL0osyPDNT0nNUTV2VTV2LsrPL4GwciucU3NyVI0MMlNUjV1UjYwMgFjVyA2HrCFY1qAgsSg1rwSLBiADYTaQg2Y1AA== -------------------------------------------------------------------------------- /docs/api-gateway-metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/api-gateway-metrics.png -------------------------------------------------------------------------------- /docs/api-gateway-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/api-gateway-trace.png -------------------------------------------------------------------------------- /docs/correlated-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/correlated-logs.png -------------------------------------------------------------------------------- /docs/free-tier/api-gateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/api-gateway.png -------------------------------------------------------------------------------- /docs/free-tier/cloudfront.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/cloudfront.png -------------------------------------------------------------------------------- /docs/free-tier/cloudtrail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/cloudtrail.png -------------------------------------------------------------------------------- /docs/free-tier/cloudwatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/cloudwatch.png -------------------------------------------------------------------------------- /docs/free-tier/cognito.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/cognito.png -------------------------------------------------------------------------------- /docs/free-tier/lambda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/lambda.png -------------------------------------------------------------------------------- /docs/free-tier/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/s3.png -------------------------------------------------------------------------------- /docs/free-tier/x-ray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/free-tier/x-ray.png -------------------------------------------------------------------------------- /docs/openapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/docs/openapi.png -------------------------------------------------------------------------------- /infrastructure/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | !/.gitattributes 3 | !/.projen/tasks.json 4 | !/.projen/deps.json 5 | !/.projen/files.json 6 | !/pyproject.toml 7 | !/setup.cfg 8 | !/setup.py 9 | *.env 10 | *venv 11 | *.venv 12 | *pyc* 13 | dist 14 | build 15 | *.whl 16 | *egg-info 17 | 18 | # ignore the generated static site; this folder is generated from the 19 | # minecraft-platform-frontend-poc/ package in the root of the project 20 | src/cdk_minecraft/resources/minecraft-platform-frontend-static -------------------------------------------------------------------------------- /infrastructure/MANIFEST.in: -------------------------------------------------------------------------------- 1 | # This file is a registry of all files that are placed in the final pip-installable awscdk-minecraft package. 2 | # 3 | # The further down a statement, the higher the priority. Statements override statements above them. 4 | # A summary of the syntax in this file can be found here: https://packaging.python.org/en/latest/guides/using-manifest-in/ 5 | 6 | # backend fastapi app 7 | recursive-include src/cdk_minecraft/resources/minecraft-platform-backend-api/ *.md 8 | recursive-include src/cdk_minecraft/resources/minecraft-platform-backend-api/ setup.cfg setup.py pyproject.toml 9 | recursive-include src/cdk_minecraft/resources/minecraft-platform-backend-api/resources/ * 10 | recursive-include src/cdk_minecraft/resources/minecraft-platform-backend-api/src/ * 11 | 12 | # server deployer aws cdk app 13 | recursive-include src/cdk_minecraft/resources/awscdk-minecraft-server-deployer/ *.md 14 | recursive-include src/cdk_minecraft/resources/awscdk-minecraft-server-deployer/ *.sh 15 | recursive-include src/cdk_minecraft/resources/awscdk-minecraft-server-deployer/ setup.cfg setup.py pyproject.toml 16 | recursive-include src/cdk_minecraft/resources/awscdk-minecraft-server-deployer/resources/ * 17 | recursive-include src/cdk_minecraft/resources/awscdk-minecraft-server-deployer/src/ * 18 | 19 | # static web files generated from the frontend project 20 | recursive-include src/cdk_minecraft/resources/minecraft-platform-frontend-static/ * 21 | 22 | global-exclude *.egg-info 23 | global-exclude *.pyc 24 | -------------------------------------------------------------------------------- /infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # `awscdk-minecraft` 2 | 3 | ## Installation 4 | 5 | `pip install awscdk-minecraft` 6 | 7 | ## System requirements 8 | 9 | - AWS CDK CLI (and `npm`/`node` by extension) 10 | - Docker, installed and running 11 | -------------------------------------------------------------------------------- /infrastructure/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from aws_cdk import App, Environment, Tags 5 | from fastapi_iac.stack import ServerlessFastAPIStack 6 | 7 | THIS_DIR = Path(__file__).parent 8 | FAST_API_PACKAGE_DIR = THIS_DIR / "../rest-api" 9 | 10 | # for development, use account/region from cdk cli 11 | DEV_ENV = Environment(account=os.environ["AWS_ACCOUNT_ID"], region=os.getenv("AWS_REGION")) 12 | 13 | APP = App() 14 | 15 | ServerlessFastAPIStack( 16 | scope=APP, 17 | construct_id="serverless-fastapi", 18 | # we don't have a frontend (yet) 19 | frontend_cors_url="https://ericriddoch.info", 20 | login_page_domain_prefix="serverless-fastapi", 21 | fastapi_code_dir=FAST_API_PACKAGE_DIR, 22 | enable_auth=False, 23 | env=DEV_ENV, 24 | ) 25 | 26 | Tags.of(APP).add("organization", "mlops-club") 27 | Tags.of(APP).add("project", "serverless-fastapi") 28 | Tags.of(APP).add("created-with", "awscdk-python") 29 | 30 | APP.synth() 31 | -------------------------------------------------------------------------------- /infrastructure/pyproject.toml: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | 3 | [build-system] 4 | requires = [ "setuptools>=46.1.0", "wheel", "build", "docutils" ] 5 | -------------------------------------------------------------------------------- /infrastructure/setup.cfg: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | [metadata] 3 | name = serverless-fastapi-iac 4 | author = 5 | author_email = 6 | home_page = 7 | description = 8 | # begin TODO, support both .rst and .md README files 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown; charset=UTF-8 11 | # long_description_content_type = text/x-rst; charset=UTF-8 12 | # end TODO 13 | version = 0.2.0 14 | license = Proprietary 15 | # license_file = LICENSE.txt 16 | keywords = 17 | project_urls = 18 | Documentation = https://docs.rootski.io 19 | Source = https://github.com 20 | 21 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 22 | classifiers = 23 | Programming Language :: Python :: 3.7 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Programming Language :: Python :: 3.10 27 | 28 | [options] 29 | zip_safe = False 30 | package_dir= 31 | =src 32 | packages = find: 33 | include_package_data = True 34 | test_suite = tests/unit_tests 35 | python_requires = >= 3.6.* 36 | install_requires = 37 | importlib-metadata; python_version<"3.8" 38 | aws-cdk-lib >=2.45.0, <3.0.0 39 | constructs >=10.0.5, <11.0.0 40 | 41 | [options.packages.find] 42 | where = src 43 | 44 | exclude = 45 | tests 46 | 47 | 48 | [options.extras_require] 49 | base= 50 | importlib-metadata; python_version<"3.8" 51 | aws-cdk-lib >=2.45.0, <3.0.0 52 | constructs >=10.0.5, <11.0.0 53 | aws_cdk.aws_lambda_python_alpha 54 | 55 | test= 56 | pytest 57 | pytest-cov 58 | pytest-xdist 59 | 60 | publish = 61 | twine 62 | 63 | dev = 64 | %(test)s 65 | 66 | all = 67 | %(dev)s 68 | %(base)s 69 | %(publish)s 70 | %(test)s 71 | 72 | 73 | 74 | 75 | [bdist_wheel] 76 | universal = true 77 | 78 | [check] 79 | metadata = true 80 | restructuredtext = true 81 | strict = true 82 | 83 | [sdist] 84 | formats = zip, gztar 85 | 86 | [tool:pytest] 87 | markers = 88 | foundational: Tests that must pass for subsequent tests to run. 89 | slow: Tests that take a long time to execute -------------------------------------------------------------------------------- /infrastructure/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /infrastructure/src/fastapi_iac/__init__.py: -------------------------------------------------------------------------------- 1 | """Modules for awscdk-minecraft.""" 2 | from .stack import ServerlessFastAPIStack # noqa: F401 3 | -------------------------------------------------------------------------------- /infrastructure/src/fastapi_iac/rest_api.py: -------------------------------------------------------------------------------- 1 | """Stack defining an API Gateway mapping to a Lambda function with the FastAPI app.""" 2 | 3 | 4 | from pathlib import Path 5 | from textwrap import dedent 6 | from typing import Dict, List, Optional 7 | 8 | import aws_cdk as cdk 9 | from aws_cdk import CfnOutput 10 | from aws_cdk import aws_apigateway as apigw 11 | from aws_cdk import aws_iam as iam 12 | from aws_cdk import aws_lambda as lambda_ 13 | from constructs import Construct 14 | 15 | REACT_LOCALHOST = "http://localhost:3000" 16 | 17 | ADOT_PYTHON_LAYER_VERSION = "arn:aws:lambda:us-west-2:901920570463:layer:aws-otel-python-amd64-ver-1-15-0:2" 18 | DEFAULT_STAGE_NAME = "prod" 19 | 20 | 21 | class LambdaFastAPI(Construct): 22 | """An API Gateway mapping to a Lambda function with the backend code inside.""" 23 | 24 | def __init__( 25 | self, 26 | scope: Construct, 27 | construct_id: str, 28 | frontend_cors_url: str, 29 | fast_api_code_dir: Path, 30 | authorizer: apigw.CfnAuthorizer = None, 31 | lambda_env_var_overrides: Optional[Dict[str, str]] = None, 32 | **kwargs, 33 | ): 34 | super().__init__(scope, construct_id, **kwargs) 35 | 36 | lambda_env_var_overrides = lambda_env_var_overrides or {} 37 | 38 | cdk.Stack.of(self) 39 | 40 | #: lambda function containing the minecraft FastAPI application code 41 | self._fast_api_function: lambda_.Function = make_fast_api_function( 42 | scope=self, 43 | construct_id=f"Lambda", 44 | frontend_cors_url=frontend_cors_url, 45 | fast_api_src_code_dir=fast_api_code_dir, 46 | env_vars=lambda_env_var_overrides, 47 | stage_name=DEFAULT_STAGE_NAME, 48 | ) 49 | instrument_python_lambda_with_opentelemetry(scope=self, lambda_function=self._fast_api_function) 50 | 51 | # add logging instrumentation 52 | self._fast_api_function.add_layers( 53 | make_fastapi_dependencies_layer( 54 | scope=self, 55 | construct_id="fastapi-app-dependencies", 56 | fast_api_src_code_dir=fast_api_code_dir, 57 | otel_instrumentation_packages=[ 58 | "logging", 59 | "fastapi", 60 | ], 61 | ) 62 | ) 63 | self._fast_api_function.add_environment("OTEL_SERVICE_NAME", "serverless-fastapi") 64 | self._fast_api_function.add_environment("OTEL_PYTHON_LOG_CORRELATION", "true") 65 | self._fast_api_function.add_environment("OTEL_PYTHON_LOG_LEVEL", "info") 66 | self._fast_api_function.add_environment("OTEL_PROPAGATORS", "xray") 67 | self._fast_api_function.add_environment("OTEL_PYTHON_ID_GENERATOR", "xray") 68 | 69 | self._api = apigw.RestApi( 70 | self, 71 | f"-RestApi", 72 | deploy_options=apigw.StageOptions( 73 | stage_name=DEFAULT_STAGE_NAME, 74 | metrics_enabled=True, 75 | tracing_enabled=True, 76 | description="Production stage", 77 | throttling_burst_limit=10, 78 | throttling_rate_limit=2, 79 | ), 80 | description="Serverless FastAPI", 81 | ) 82 | 83 | proxy: apigw.Resource = self._api.root.add_resource(path_part="{proxy+}") 84 | 85 | # configure the /{proxy+} resource to proxy to the lambda function and handle CORS 86 | configure_lambda_proxy_and_cors( 87 | authorizer=authorizer, 88 | frontend_cors_url=frontend_cors_url, 89 | fastapi_function=self._fast_api_function, 90 | resource=proxy, 91 | ) 92 | 93 | # we must configure / to in the same way because / is a different resource than /{proxy+} 94 | configure_lambda_proxy_and_cors( 95 | authorizer=authorizer, 96 | frontend_cors_url=frontend_cors_url, 97 | fastapi_function=self._fast_api_function, 98 | resource=self._api.root, 99 | ) 100 | 101 | cdk.Tags.of(self).add("construct", "lambda-fast-api") 102 | CfnOutput(self, "EndpointURL", value=self._api.url) 103 | 104 | @property 105 | def role(self) -> iam.Role: 106 | """The IAM role of the lambda function.""" 107 | return self._fast_api_function.role 108 | 109 | @property 110 | def url(self) -> str: 111 | """The URL of the API Gateway.""" 112 | return self._api.url 113 | 114 | 115 | def configure_lambda_proxy_and_cors( 116 | resource: apigw.Resource, 117 | frontend_cors_url: str, 118 | authorizer: Optional[apigw.Authorizer], 119 | fastapi_function: lambda_.Function, 120 | ): 121 | """Configure the API Gateway resource to proxy requests to the lambda function.""" 122 | resource.add_method( 123 | http_method="ANY", 124 | integration=apigw.LambdaIntegration( 125 | handler=fastapi_function, 126 | proxy=True, 127 | ), 128 | authorizer=authorizer, 129 | ) 130 | 131 | add_cors_options_method(resource=resource, frontend_cors_url=frontend_cors_url) 132 | 133 | 134 | def make_fast_api_function( 135 | scope: Construct, 136 | construct_id: str, 137 | frontend_cors_url: str, 138 | fast_api_src_code_dir: Path, 139 | stage_name: str, 140 | memory_size_mb: int = 512, 141 | timeout_seconds: int = 30, 142 | env_vars: Optional[Dict[str, str]] = None, 143 | ) -> lambda_.Function: 144 | r""" 145 | Create a lambda function with the FastAPI app. 146 | 147 | To prepare the python depencies for the lambda function, this stack 148 | will essentially run the following command: 149 | 150 | .. code:: bash 151 | 152 | docker run \ 153 | --rm \ 154 | -v "path/to/awscdk-minecraft-api:/assets_input" \ 155 | -v "path/to/cdk.out/asset.:/assets_output" \ 156 | lambci/lambda:build-python3.8 \ 157 | /bin/bash -c "... several commands to install the requirements to /assets_output ..." 158 | 159 | The reason for using docker to install the requirements is because the "lambci/lambda:build-pythonX.X" image 160 | uses the same underlying operating system as is used in the real AWS Lambda runtime. This means that 161 | python packages that rely on compiled C/C++ binaries will be compiled correctly for the AWS Lambda runtime. 162 | If we did not do it this way, packages such as pandas, numpy, psycopg2-binary, asyncpg, sqlalchemy, and others 163 | relying on C/C++ bindings would not work when uploaded to lambda. 164 | 165 | We use the ``lambci/*`` images instead of the images maintained by AWS CDK because the AWS CDK images 166 | were failing to correctly install C/C++ based python packages. An extra benefit of using ``lambci/*`` over 167 | the AWS CDK images is that the ``lambci/*`` images are in docker hub so they can be pulled without doing any 168 | sort of ``docker login`` command before executing this script. The AWS CDK images are stored in public.ecr.aws 169 | which requires a ``docker login`` command to be run first. 170 | 171 | Logging and Tracing with OpenTelemetry: 172 | 173 | This function sets up AWS XRay monitoring and correlated logs based on the following docs: 174 | https://aws-otel.github.io/docs/getting-started/lambda/lambda-python 175 | 176 | :param scope: The CDK scope to attach this lambda function to. 177 | :param construct_id: The id of this lambda function. 178 | :param frontend_cors_url: The URL of the frontend that will be making requests to this lambda function. 179 | :param fast_api_src_code_dir: The directory containing the FastAPI application code. 180 | :param memory_size_mb: The amount of memory in megabytes to allocate to the lambda function runtime. 181 | :param timeout_seconds: The amount of time to allow the lambda function to run before timing out. 182 | :param env_vars: A dictionary of environment variables to make available within the lambda function runtime. 183 | """ 184 | env_vars: dict = env_vars or {} 185 | 186 | fastapi_function = lambda_.Function( 187 | scope, 188 | id="FastAPIFunction", 189 | # # layer with 190 | # # (a) AWS Distro of OpenTelemetry in Python--a python library 191 | # # (b) reduced version of the ADOT Collector Lambda plugin 192 | # adot_instrumentation=lambda_.AdotInstrumentationConfig( 193 | # exec_wrapper=lambda_.AdotLambdaExecWrapper.PROXY_HANDLER, 194 | # layer_version=lambda_.AdotLayerVersion.lambda_.AdotLambdaLayerPythonSdkVersion.LATEST, 195 | # ), 196 | tracing=lambda_.Tracing.ACTIVE, 197 | timeout=cdk.Duration.seconds(timeout_seconds), 198 | memory_size=memory_size_mb, 199 | runtime=lambda_.Runtime.PYTHON_3_8, 200 | handler="index.handler", 201 | code=lambda_.Code.from_asset( 202 | path=str(fast_api_src_code_dir), 203 | bundling=cdk.BundlingOptions( 204 | # learn about this here: 205 | # https://docs.aws.amazon.com/cdk/api/v1/python/aws_cdk.aws_lambda/README.html#bundling-asset-code 206 | # Using this lambci image makes it so that dependencies with C-binaries compile correctly for the lambda runtime. 207 | # The AWS CDK python images were not doing this. Relevant dependencies are: pandas, asyncpg, and psycogp2-binary. 208 | image=cdk.DockerImage.from_registry(image="lambci/lambda:build-python3.8"), 209 | command=[ 210 | "bash", 211 | "-c", 212 | "mkdir -p /asset-output" 213 | # + "&& pip install -r ./aws-lambda/requirements.txt -t /asset-output" 214 | + "&& pip install . --target /asset-output " + "&& cp ./aws-lambda/index.py /asset-output" 215 | # + "&& rm -rf /asset-output/boto3 /asset-output/botocore", 216 | ], 217 | ), 218 | ), 219 | environment={ 220 | "FRONTEND_CORS_URL": frontend_cors_url, 221 | "ROOT_PATH": f"/{stage_name}", 222 | **env_vars, 223 | }, 224 | ) 225 | 226 | return fastapi_function 227 | 228 | 229 | def make_fastapi_dependencies_layer( 230 | scope: Construct, 231 | construct_id: str, 232 | fast_api_src_code_dir: Path, 233 | otel_instrumentation_packages: List[str], 234 | ) -> lambda_.LayerVersion: 235 | """ 236 | Install all of the FastAPI dependencies into a zip file which will be published to S3. 237 | 238 | According to the lambda layer docs, the zip file containing the layer contents needs to have 239 | all of the libraries inside of a /python/ folder. In the lambda runtime, these files will 240 | end up being located at /opt/python/. 241 | https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html#configuration-layers-path 242 | """ 243 | pip_install_otel_instrumentation_packages_cmd = "pip install " 244 | pip_install_otel_instrumentation_packages_cmd += " ".join( 245 | [ 246 | f"--upgrade opentelemetry-instrumentation-{pkg} --target /asset-output/python" 247 | for pkg in otel_instrumentation_packages 248 | ] 249 | ) 250 | 251 | return lambda_.LayerVersion( 252 | scope, 253 | id=construct_id, 254 | compatible_runtimes=[lambda_.Runtime.PYTHON_3_8], 255 | code=lambda_.Code.from_asset( 256 | path=str(fast_api_src_code_dir / "aws-lambda"), 257 | bundling=cdk.BundlingOptions( 258 | # learn about this here: 259 | # https://docs.aws.amazon.com/cdk/api/v1/python/aws_cdk.aws_lambda/README.html#bundling-asset-code 260 | # Using this lambci image makes it so that dependencies with C-binaries compile correctly for the lambda runtime. 261 | # The AWS CDK python images were not doing this. Relevant dependencies are: pandas, asyncpg, and psycogp2-binary. 262 | image=cdk.DockerImage.from_registry(image="lambci/lambda:build-python3.8"), 263 | command=[ 264 | "bash", 265 | "-c", 266 | "mkdir -p /asset-output/python/" 267 | # install dependencies into the /asset-output/python/ folder 268 | + " && pip install -r ./requirements.txt --target /asset-output/python/" 269 | + " && " 270 | + pip_install_otel_instrumentation_packages_cmd 271 | + " && rm -rf /asset-output/python/boto3 /asset-output/python/botocore", 272 | ], 273 | ), 274 | exclude=["index.py"], 275 | ), 276 | ) 277 | 278 | 279 | def instrument_python_lambda_with_opentelemetry(scope: Construct, lambda_function: lambda_.Function) -> None: 280 | """ 281 | Instrument a Python function following the official docs. 282 | 283 | Docs here: https://aws-otel.github.io/docs/getting-started/lambda/lambda-python 284 | 285 | In theory, the following code option in the aws Lambda (and Lambda Python Alpha) construct 286 | should do exactly what this function does. After trying it out in January 2023, I found 287 | that it uses OTel version 0-13 which was not the latest. In addition, it does not 288 | set a required environment variable that must be set for the most recent version to work. 289 | 290 | ```python 291 | lambda_.Function( 292 | ... 293 | # layer with 294 | # (a) AWS Distro of OpenTelemetry in Python--a python library 295 | # (b) reduced version of the ADOT Collector Lambda plugin 296 | adot_instrumentation=lambda_.AdotInstrumentationConfig( 297 | exec_wrapper=lambda_.AdotLambdaExecWrapper.PROXY_HANDLER, 298 | layer_version=lambda_.AdotLayerVersion.lambda_.AdotLambdaLayerPythonSdkVersion.LATEST, 299 | ), 300 | ... 301 | ) 302 | ``` 303 | 304 | The internals of the layer can be found on GitHub here: 305 | https://github.com/aws-observability/aws-otel-lambda/blob/main/python/scripts/otel-instrument 306 | 307 | NOTE: This function should not be necessary. It is meant to be built into the 308 | official L2 Lambda construct 309 | """ 310 | otel_layer: lambda_.LayerVersion = lookup_aws_distro_of_open_telemetry_python_lambda_layer(scope=scope) 311 | lambda_function.add_layers(otel_layer) 312 | lambda_function.add_environment("AWS_LAMBDA_EXEC_WRAPPER", "/opt/otel-instrument") 313 | 314 | 315 | def build_python_lambda_layer(scope: Construct, requirements: List[str]) -> lambda_.LayerVersion: 316 | """ 317 | Build a lambda layer from a list of requirements. 318 | 319 | The layer will be built using the lambci/lambda:build-python3.8 image. 320 | This image is used to build lambda layers for the python 3.8 runtime. 321 | """ 322 | # prepare to use a placeholder directory that we won't actually use to install the requirements 323 | tmp_dir = Path("/tmp") 324 | tmp_dir.mkdir(exist_ok=True) 325 | 326 | pip_install_reqs_cmd = "pip install " 327 | pip_install_reqs_cmd += " ".join([f"{req} --target /asset-output" for req in requirements]) 328 | print(pip_install_reqs_cmd) 329 | 330 | return lambda_.LayerVersion( 331 | scope=scope, 332 | id="otel-python38", 333 | code=lambda_.Code.from_asset( 334 | path=str(tmp_dir), 335 | bundling=cdk.BundlingOptions( 336 | image=cdk.DockerImage.from_registry(image="lambci/lambda:build-python3.8"), 337 | command=[ 338 | "bash", 339 | "-c", 340 | "mkdir -p /asset-output && " + pip_install_reqs_cmd, 341 | ], 342 | ), 343 | ), 344 | ) 345 | 346 | 347 | def write_python_requirements_file(requirements: List[str], requirements_file_dir: Path) -> Path: 348 | requirements_txt_contents: str = "\n".join(requirements) 349 | requirements_file = requirements_file_dir / "requirements.txt" 350 | requirements_file.write_text(requirements_txt_contents) 351 | return requirements_file 352 | 353 | 354 | def make_cors_preflight_mock_integration(frontend_cors_url: str) -> apigw.MockIntegration: 355 | """ 356 | Create a MockIntegration that will be used for the OPTIONS method to return correct CORS headers during the preflight request. 357 | 358 | This web app helps you determine what your CORS setup should be for api gateway: https://cors.serverlessland.com/ 359 | """ 360 | mock_integration = apigw.MockIntegration( 361 | passthrough_behavior=apigw.PassthroughBehavior.WHEN_NO_TEMPLATES, 362 | request_templates={ 363 | "application/json": '{"statusCode": 200}', 364 | }, 365 | integration_responses=[ 366 | apigw.IntegrationResponse( 367 | status_code="200", 368 | # response_parameters contains static values that are returned by the Mock integration 369 | # at every single request. You can use this to set a response body, but in this case 370 | # we are only setting headers. 371 | response_parameters={ 372 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", 373 | "method.response.header.Access-Control-Allow-Methods": "'OPTIONS,GET,PUT,POST,DELETE'", 374 | "method.response.header.Access-Control-Allow-Origin": f"'{frontend_cors_url}'", 375 | "method.response.header.Content-Type": "'application/json'", 376 | }, 377 | response_templates={ 378 | # API Gateway only supports specifying a single CORS origin for the Access-Control-Allow-Origin header; 379 | # but it's useful for development to include http://localhost:3000 so that we can hit the production 380 | # API from our local frontend. We can't do a comma-separated list of origins like "https://,http://localhost:3000`", 381 | # API gateway only supports one specific origin or '*'--but '*' is not allowed when credentials are passed 382 | # with HTTP requests such as we are doing with AWS cognito. 383 | # 384 | # So, we use this template written with the "Apache Velocity Templating Language" 385 | # (similar to Jinja, but has syntax for setting variables). AWS exposes certain variables such as $ whose 386 | # values we can use in our template. 387 | # 388 | # This template transforms the headers/payload that come out of the MockIntegration. In this case, 389 | # it transforms the fixed content we hard coded in "response_parameters" ^^^. That way we can 390 | # choose which origin to return in the final response. 391 | "application/json": dedent( 392 | f"""\ 393 | #set($origin = $input.params("Origin")) 394 | #if($origin == "") 395 | #set($origin = $input.params("origin")) 396 | #end 397 | #if($origin == "{REACT_LOCALHOST}" || $origin == "{frontend_cors_url}") 398 | #set($context.responseOverride.header.Access-Control-Allow-Origin = $origin) 399 | #end 400 | """ 401 | ), 402 | }, 403 | ) 404 | ], 405 | content_handling=apigw.ContentHandling.CONVERT_TO_TEXT, 406 | credentials_passthrough=True, 407 | ) 408 | 409 | return mock_integration 410 | 411 | 412 | def add_cors_options_method( 413 | resource: apigw.Resource, 414 | frontend_cors_url: str, 415 | ) -> None: 416 | """Add an OPTIONS method to the resource to allow CORS requests.""" 417 | resource.add_method( 418 | http_method="OPTIONS", 419 | integration=make_cors_preflight_mock_integration(frontend_cors_url=frontend_cors_url), 420 | authorization_type=apigw.AuthorizationType.NONE, 421 | method_responses=[ 422 | apigw.MethodResponse( 423 | status_code="200", 424 | response_parameters={ 425 | # True means that the method response header will be passed through from the integration response. 426 | # Just because the MockIntegration returns values, does not mean those values make it 427 | # to the client making the request. Here, we must explicitly declare headers, payload fields, 428 | # etc. that we want to pass back to the user. 429 | "method.response.header.Access-Control-Allow-Headers": True, 430 | "method.response.header.Access-Control-Allow-Methods": True, 431 | "method.response.header.Access-Control-Allow-Origin": True, 432 | "method.response.header.Content-Type": True, 433 | }, 434 | ) 435 | ], 436 | ) 437 | 438 | 439 | def lookup_aws_distro_of_open_telemetry_python_lambda_layer(scope: Construct) -> lambda_.LayerVersion: 440 | """ 441 | Look up the ARN of the AWS Distro of OpenTelemetry Python Lambda layer. 442 | 443 | Official docs for this layer and the role it plays in the OpenTelemetry setup: 444 | https://aws-otel.github.io/docs/getting-started/lambda/lambda-python 445 | 446 | Formula: `arn:aws:lambda::901920570463:layer:aws-otel-python--ver-1-15-0:2` 447 | """ 448 | return lambda_.LayerVersion.from_layer_version_arn( 449 | scope=scope, id="aws-otel-python", layer_version_arn=ADOT_PYTHON_LAYER_VERSION 450 | ) 451 | -------------------------------------------------------------------------------- /infrastructure/src/fastapi_iac/stack.py: -------------------------------------------------------------------------------- 1 | """Boilerplate stack to make sure the CDK is set up correctly.""" 2 | 3 | 4 | from pathlib import Path 5 | from typing import List 6 | 7 | import aws_cdk as cdk 8 | # coginto imports, user pool and client 9 | # coginto imports, user pool and client 10 | # imports for lambda functions and API Gateway 11 | from aws_cdk import CfnOutput, Duration, Stack 12 | from aws_cdk import aws_apigateway as apigw 13 | from aws_cdk import aws_cognito as cognito 14 | from aws_cdk import aws_s3 as s3 15 | from constructs import Construct 16 | from fastapi_iac.rest_api import LambdaFastAPI 17 | 18 | 19 | class ServerlessFastAPIStack(Stack): 20 | """Class to create a stack for the PaaS. 21 | 22 | :param scope: The scope of the stack 23 | :param construct_id: The ID of the stack 24 | :param **kwargs: Additional arguments to pass to the stack 25 | 26 | :ivar job_queue: The job queue for the batch jobs 27 | :ivar _server_deployer_job_definition: The job definition for the batch jobs 28 | :ivar mc_deployment_state_machine: The state machine to deploy a server 29 | :ivar mc_destruction_state_machine: The state machine to destroy a server 30 | :ivar frontend_static_site: The static website for the frontend 31 | :ivar frontend_url: The URL of the frontend 32 | :ivar cognito_service: The Cognito service for the frontend 33 | :ivar mc_rest_api: The REST API for the PaaS 34 | """ 35 | 36 | def __init__( 37 | self, 38 | scope: Construct, 39 | construct_id: str, 40 | frontend_cors_url: str, 41 | login_page_domain_prefix: str, 42 | fastapi_code_dir: Path, 43 | enable_auth: bool = False, 44 | **kwargs, 45 | ) -> None: 46 | super().__init__(scope, construct_id, **kwargs) 47 | 48 | example_bucket = s3.Bucket( 49 | scope=self, 50 | id="ExampleBucket", 51 | # TODO: make this RETAIN later 52 | removal_policy=cdk.RemovalPolicy.DESTROY, 53 | ) 54 | 55 | cognito_service = CognitoLoginPage( 56 | scope=self, 57 | construct_id="CognitoService", 58 | frontend_url=frontend_cors_url, 59 | login_page_domain_prefix=login_page_domain_prefix, 60 | ) 61 | 62 | api = LambdaFastAPI( 63 | scope=self, 64 | construct_id="ServerlessFastAPI", 65 | authorizer=cognito_service.make_apigw_authorizer() if enable_auth else None, 66 | frontend_cors_url=frontend_cors_url, 67 | fast_api_code_dir=fastapi_code_dir, 68 | lambda_env_var_overrides={ 69 | "S3_BUCKET_NAME": example_bucket.bucket_name, 70 | }, 71 | ) 72 | 73 | example_bucket.grant_read_write(api.role) 74 | 75 | 76 | class CognitoLoginPage(Construct): 77 | """Cognito User Pool with a Login Page.""" 78 | 79 | def __init__( 80 | self, 81 | scope: Construct, 82 | construct_id: str, 83 | frontend_url: str, 84 | login_page_domain_prefix: str, 85 | ) -> None: 86 | super().__init__(scope, construct_id) 87 | 88 | # create a user pool, do not allow users to sign up themselves. 89 | # https://docs.aws.amazon.com/cdk/api/latest/python/aws_cdk.aws_cognito/UserPool.html 90 | 91 | self.user_pool = cognito.UserPool( 92 | scope=scope, 93 | id="UserPool", 94 | user_pool_name="UserPool", 95 | self_sign_up_enabled=False, 96 | auto_verify=cognito.AutoVerifiedAttrs(email=True), 97 | standard_attributes={ 98 | "email": {"required": True, "mutable": True}, 99 | }, 100 | custom_attributes={"custom_username": cognito.StringAttribute(min_len=3, max_len=16, mutable=True)}, 101 | password_policy=cognito.PasswordPolicy( 102 | min_length=8, 103 | require_digits=False, 104 | require_lowercase=False, 105 | require_uppercase=False, 106 | require_symbols=False, 107 | ), 108 | ) 109 | 110 | # add a client to the user pool, handle JWT tokens 111 | # https://docs.aws.amazon.com/cdk/api/latest/python/aws_cdk.aws_cognito/UserPoolClient.html 112 | allowed_oauth_scopes = [ 113 | cognito.OAuthScope.EMAIL, 114 | cognito.OAuthScope.OPENID, 115 | cognito.OAuthScope.PROFILE, 116 | cognito.OAuthScope.COGNITO_ADMIN, 117 | ] 118 | self.client = self.user_pool.add_client( 119 | "UserPoolClient", 120 | user_pool_client_name="UserPoolClient", 121 | generate_secret=False, 122 | auth_flows=cognito.AuthFlow(user_password=True, user_srp=True, admin_user_password=True), 123 | o_auth=cognito.OAuthSettings( 124 | flows=cognito.OAuthFlows(authorization_code_grant=True, implicit_code_grant=True), 125 | scopes=allowed_oauth_scopes, 126 | callback_urls=["http://localhost:3000", frontend_url], 127 | logout_urls=["http://localhost:3000", frontend_url], 128 | ), 129 | id_token_validity=Duration.days(1), 130 | access_token_validity=Duration.days(1), 131 | refresh_token_validity=Duration.days(1), 132 | prevent_user_existence_errors=True, 133 | ) 134 | 135 | self.allowed_oauth_scopes: List[str] = [scope.scope_name for scope in allowed_oauth_scopes] 136 | 137 | read_scope = cognito.ResourceServerScope(scope_name=".read", scope_description=" read scope") 138 | resource_server = cognito.UserPoolResourceServer( 139 | scope=self, 140 | id="-resource-server", 141 | identifier="-api-resource-server", 142 | user_pool=self.user_pool, 143 | scopes=[read_scope], 144 | ) 145 | 146 | client_read_scope = cognito.OAuthScope.resource_server(resource_server, read_scope) 147 | 148 | self.client_credentials = self.user_pool.add_client( 149 | "ClientCredentialsClient", 150 | user_pool_client_name="ClientCredentialsClient", 151 | generate_secret=True, 152 | auth_flows=cognito.AuthFlow(user_password=True, user_srp=True, admin_user_password=True), 153 | o_auth=cognito.OAuthSettings( 154 | flows=cognito.OAuthFlows(client_credentials=True), 155 | scopes=[client_read_scope], 156 | callback_urls=["https://localhost:3000", frontend_url], 157 | logout_urls=["https://localhost:3000", frontend_url], 158 | ), 159 | id_token_validity=Duration.days(1), 160 | access_token_validity=Duration.days(1), 161 | refresh_token_validity=Duration.days(1), 162 | prevent_user_existence_errors=True, 163 | ) 164 | 165 | # add a domain to the user pool 166 | self.domain = self.user_pool.add_domain( 167 | id="UserPoolDomain", 168 | cognito_domain=cognito.CognitoDomainOptions(domain_prefix=login_page_domain_prefix), 169 | ) 170 | 171 | self.fully_qualified_domain_name = f"{self.domain.domain_name}.auth.{scope.region}.amazoncognito.com" 172 | 173 | # add a CfnOutput to get the user pool domain 174 | CfnOutput( 175 | scope=scope, 176 | id="UserPoolDomain", 177 | value=self.domain.domain_name, 178 | ) 179 | 180 | def make_apigw_authorizer(self) -> apigw.CognitoUserPoolsAuthorizer: 181 | apigw.CognitoUserPoolsAuthorizer( 182 | scope=self, 183 | id="CognitoAuthorizer", 184 | cognito_user_pools=[self.user_pool], 185 | ) 186 | -------------------------------------------------------------------------------- /infrastructure/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/infrastructure/tests/__init__.py -------------------------------------------------------------------------------- /infrastructure/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Reusable fixtures for the tests.""" 2 | import os # noqa: D100 C0114 3 | 4 | import pytest 5 | from aws_cdk import Environment 6 | 7 | 8 | @pytest.fixture(scope="module") 9 | def dev_env(): # noqa: D103 10 | return Environment(account=os.environ["AWS_ACCOUNT_ID"], region=os.environ["AWS_REGION"]) 11 | -------------------------------------------------------------------------------- /infrastructure/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Do realistic testing of user authentication. 3 | 4 | These tests require: 5 | 1. AWS Cognito 6 | - A User Pool is spun up 7 | - An app/web client is registered for that user pool 8 | 9 | 2. Config 10 | - The ``rootski-config.yml`` file must be configured with the AWS Cognito values 11 | 12 | 3. The ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` env vars need to be set for an IAM user with admin access to the configured user pool 13 | """ 14 | 15 | from pprint import pprint 16 | from typing import Tuple 17 | 18 | import boto3 19 | import pytest 20 | 21 | TEST_USER = { 22 | "email": "test.user@minecraft-server-app.com", 23 | "password": "UserPassw0rd1!", 24 | } 25 | 26 | 27 | @pytest.fixture(scope="session") 28 | def jwt_id_token() -> str: 29 | """ 30 | Create and clean up a real user in an AWS Cognito User Pool. 31 | 32 | :yield: a JWT token that can be used to make API requests on behalf of the user 33 | """ 34 | try: 35 | delete_cognito_user(user_pool_id=config.cognito_user_pool_id, email=TEST_USER["email"]) 36 | except boto3.client("cognito-idp").exceptions.UserNotFoundException as e: 37 | print("No user to delete") 38 | register_cognito_user( 39 | email=TEST_USER["email"], 40 | password=TEST_USER["password"], 41 | app_client_id=config.cognito_web_client_id, 42 | ) 43 | confirm_user_email(user_pool_id=config.cognito_user_pool_id, username=TEST_USER["email"]) 44 | jwt_access_token, jwt_id_token, jwt_refresh_token = sign_in_user( 45 | email=TEST_USER["email"], 46 | password=TEST_USER["password"], 47 | user_pool_id=config.cognito_user_pool_id, 48 | app_client_id=config.cognito_web_client_id, 49 | ) 50 | yield jwt_id_token 51 | # clean up the user 52 | delete_cognito_user(user_pool_id=config.cognito_user_pool_id, email=TEST_USER["email"]) 53 | 54 | 55 | ######################################### 56 | # --- Helper functions for fixtures --- # 57 | ######################################### 58 | 59 | 60 | def register_cognito_user(email: str, password: str, app_client_id: str): 61 | """ 62 | Raises: 63 | ClientError: if the user can't be signed up; for example, the user 64 | may already exist or the app_client_id could be wrong 65 | """ 66 | cognito_idp = boto3.client("cognito-idp") 67 | # Add user to pool 68 | sign_up_response = cognito_idp.sign_up( 69 | ClientId=app_client_id, 70 | Username=email, 71 | Password=password, 72 | UserAttributes=[{"Name": "email", "Value": email}], 73 | ) 74 | pprint(sign_up_response) 75 | 76 | 77 | def confirm_user_email(user_pool_id: str, username: str): 78 | """Confirm a user's email address. For rootski, the ``username`` is their email.""" 79 | cognito_idp = boto3.client("cognito-idp") 80 | print(" Confirming user...") 81 | # Use Admin powers to confirm user. Normally the user would 82 | # have to provide a code or click a link received by email 83 | confirm_sign_up_response = cognito_idp.admin_confirm_sign_up(UserPoolId=user_pool_id, Username=username) 84 | pprint(confirm_sign_up_response) 85 | 86 | 87 | def sign_in_user(email: str, password: str, user_pool_id: str, app_client_id: str) -> Tuple[str, str, str]: 88 | """ 89 | Acquire a set of authentication tokens on behalf of the given user. 90 | 91 | :param app_client_id: ID of a Cognito application with suficient privileges to 92 | execute the ADMIN_NO_SRP_AUTH auth flow. 93 | """ 94 | cognito_idp = boto3.client("cognito-idp") 95 | 96 | # This is less secure, but simpler 97 | response = cognito_idp.admin_initiate_auth( 98 | AuthFlow="ADMIN_NO_SRP_AUTH", 99 | AuthParameters={"USERNAME": email, "PASSWORD": password}, 100 | UserPoolId=user_pool_id, 101 | ClientId=app_client_id, 102 | ) 103 | print("----- Log in response -----") 104 | pprint(response) 105 | print("---------------------------") 106 | # AWS official docs on using tokens with user pools: 107 | # https://amzn.to/2HbmJG6 108 | # If authentication was successful we got three tokens 109 | jwt_access_token = response["AuthenticationResult"]["AccessToken"] 110 | jwt_id_token = response["AuthenticationResult"]["IdToken"] 111 | jwt_refresh_token = response["AuthenticationResult"]["RefreshToken"] 112 | 113 | return jwt_access_token, jwt_id_token, jwt_refresh_token 114 | 115 | 116 | def delete_cognito_user(user_pool_id: str, email: str): 117 | """Delete a user with the given email from the cognito user pool with the given ID.""" 118 | cognito_idp = boto3.client("cognito-idp") 119 | cognito_idp.admin_delete_user(UserPoolId=user_pool_id, Username=email) 120 | 121 | 122 | ###################### 123 | # --- Test cases --- # 124 | ###################### 125 | 126 | 127 | def test__auth_service(config: Config, jwt_id_token: str): 128 | auth_service = AuthService.from_config(config=config) 129 | auth_service.init() 130 | assert auth_service.token_is_valid(jwt_id_token) 131 | assert auth_service.get_token_email(jwt_id_token) == TEST_USER["email"] 132 | -------------------------------------------------------------------------------- /infrastructure/tests/test_example.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aws_cdk import App 3 | from aws_cdk.assertions import Template 4 | from cdk_minecraft import MinecraftPaasStack 5 | 6 | 7 | @pytest.fixture(scope="module") 8 | def example_template(dev_env): # noqa: D103 9 | app = App() 10 | stack = MinecraftPaasStack(app, "my-stack-test", env=dev_env) 11 | template = Template.from_stack(stack) 12 | yield template 13 | 14 | 15 | def test_no_buckets_found(example_template): # noqa: D103 16 | example_template.resource_count_is("AWS::S3::Bucket", 0) 17 | -------------------------------------------------------------------------------- /linting/.flake8: -------------------------------------------------------------------------------- 1 | # to ignore an error using flake8, add a line like this 2 | # noqa: 3 | # where is a short code like D107 4 | 5 | # note that flake8 supports installing many other linting tools as plugins; we currently 6 | # are using flake8-docstrings which is a plugin that integrates pydocstyles with flake8. 7 | # pydocstyles is a linter that checks docstrings for style issues. 8 | 9 | # note: pylint catches many of the same errors as flake8. As needed, we should 10 | # disable the errors caught by flake8 in this circumstance because the pylint 11 | # warning messages are more human readable/informative. 12 | 13 | [flake8] 14 | ignore= 15 | # (pydocstyle) requires docstring in __init__, often this is trivial 16 | D107, 17 | 18 | # (pydocstyle) Multi-line docstring summary should start at the first line 19 | D212, 20 | 21 | # line too long; black catches this 22 | E501, 23 | 24 | # line break before binary operator; black causes violations of this 25 | W503, 26 | 27 | # invalid escape sequence in string such as '\[' with rich; pylint catches this 28 | W605, 29 | -------------------------------------------------------------------------------- /linting/.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | 3 | # Base profile type to use for configuration. Profiles 4 | # include: black, django, pycharm, google, open_stack, 5 | # plone, attrs, hug, wemake, appnexus. As well as any 6 | # shared profiles. 7 | profile=black 8 | 9 | # Add an explicitly defined source path (modules within 10 | # src paths have their imports automatically categorized 11 | # as first_party). Glob expansion (`*` and `**`) is 12 | # supported for this option. 13 | src_paths=tests 14 | -------------------------------------------------------------------------------- /linting/.pydocstyle.cfg: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | ignore= 3 | # doc in __init__ (because it is often just silly) 4 | D107, 5 | 6 | # Multi-line docstring summary should start at the first line 7 | D212, 8 | -------------------------------------------------------------------------------- /linting/.pylintrc: -------------------------------------------------------------------------------- 1 | # to disable certain pylint warnings, use the following format: 2 | # pylint: disable=long-form-warning-code-1,long-form-warning-code-2,... 3 | # Use the long form like "line-too-long" as opposed to the short form of the same error "C0301" 4 | 5 | [MESSAGES CONTROL] 6 | 7 | disable= 8 | # taken care of by black 9 | line-too-long, 10 | 11 | # we have a pre-commit hook that strips trailing whitespace 12 | trailing-whitespace, 13 | 14 | # pydocstyle catches this 15 | missing-function-docstring, 16 | 17 | # infrastructure/containers/postgres/automatic-backup/backup_or_restore.py 18 | # currently runs on python 3.5.3 which does not support f-strings 19 | consider-using-f-string, 20 | 21 | # pylint is currently executed by the pre-commit framework which 22 | # installs pylint in an isolated virtual environment--this means that 23 | # pylint cannot "see" any 3rd party libraries imported by code in rootski. 24 | # TODO - once we move away from "darker", execute pylint as a "local pre-commit hook" 25 | # so that it runs in the same environment as the rest of our code. 26 | import-error, 27 | 28 | # most hand-written CDK classes (constructs/stacks) have no public methods 29 | too-few-public-methods, 30 | 31 | # pytest fixtures rely on this pattern 32 | redefined-outer-name, 33 | -------------------------------------------------------------------------------- /otel-playground/README.md: -------------------------------------------------------------------------------- 1 | Install: 2 | 3 | `pip install opentelemetry-distro opentelemetry-exporter-otlp` 4 | 5 | Detect which libraries you might want to instrument: 6 | 7 | ```text 8 | usage: opentelemetry-bootstrap [-h] [--version] [-a {install,requirements}] 9 | 10 | opentelemetry-bootstrap detects installed libraries and automatically installs the relevant instrumentation packages for 11 | them. 12 | 13 | optional arguments: 14 | -h, --help show this help message and exit 15 | --version print version information 16 | -a {install,requirements}, --action {install,requirements} 17 | install - uses pip to install the new requirements using to the currently active site-package. 18 | requirements - prints out the new requirements to stdout. Action can be piped and appended to a 19 | requirements.txt file. 20 | ``` 21 | 22 | ```bash 23 | $ opentelemetry-bootstrap -a requirements 24 | 25 | opentelemetry-instrumentation-aws-lambda==0.36b0 26 | opentelemetry-instrumentation-dbapi==0.36b0 27 | opentelemetry-instrumentation-logging==0.36b0 28 | opentelemetry-instrumentation-sqlite3==0.36b0 29 | opentelemetry-instrumentation-urllib==0.36b0 30 | opentelemetry-instrumentation-wsgi==0.36b0 31 | opentelemetry-instrumentation-boto3sqs==0.36b0 32 | opentelemetry-instrumentation-botocore==0.36b0 33 | opentelemetry-instrumentation-fastapi==0.36b0 34 | opentelemetry-instrumentation-grpc==0.36b0 35 | opentelemetry-instrumentation-jinja2==0.36b0 36 | opentelemetry-instrumentation-requests==0.36b0 37 | opentelemetry-instrumentation-tortoiseorm==0.36b0 38 | opentelemetry-instrumentation-urllib3==0.36b0 39 | ``` 40 | 41 | `opentelemetry-bootstrap -a install` would not only detect, but install these ^^^. 42 | -------------------------------------------------------------------------------- /otel-playground/log.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | 3 | LOGGER = getLogger(__name__) 4 | 5 | 6 | LOGGER.debug("This is a debug message") 7 | LOGGER.info("This is an info message") 8 | LOGGER.warning("This is a warning message") 9 | LOGGER.error("This is an error message") 10 | LOGGER.critical("This is a critical message") 11 | 12 | try: 13 | 1 / 0 14 | except ZeroDivisionError: 15 | LOGGER.exception("This is an exception message") 16 | -------------------------------------------------------------------------------- /otel-playground/requirements.txt: -------------------------------------------------------------------------------- 1 | opentelemetry-distro 2 | opentelemetry-exporter-otlp 3 | 4 | opentelemetry-instrumentation-logging 5 | -------------------------------------------------------------------------------- /otel-playground/run-log.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | THIS_DIR=$(cd $(dirname $0); pwd) 4 | 5 | # This env var must be set to true in order to enable trace context injection into logs 6 | # by calling logging.basicConfig() and setting a logging format that makes use of the injected tracing variables. 7 | export OTEL_PYTHON_LOG_CORRELATION=true 8 | 9 | # export OTEL_PYTHON_LOG_FORMAT 10 | 11 | # This env var can be used to set a custom logging level: info, error, debug, warning 12 | export OTEL_PYTHON_LOG_LEVEL=info 13 | 14 | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=0.0.0.0:4317 \ 15 | OTEL_TRACES_EXPORTER=console,otlp \ 16 | OTEL_METRICS_EXPORTER=console \ 17 | OTEL_SERVICE_NAME=otel-playground-app \ 18 | opentelemetry-instrument -- python ${THIS_DIR}/log.py 19 | -------------------------------------------------------------------------------- /rest-api/Dockerfile: -------------------------------------------------------------------------------- 1 | # This Dockerfile is used for local development of the serverless FastAPI 2 | 3 | FROM python:3.9 4 | 5 | WORKDIR /app 6 | 7 | RUN pip install uvicorn 8 | 9 | COPY setup.cfg setup.py ./ 10 | COPY src/example_rest_api/__init__.py ./src/example_rest_api/ 11 | RUN pip install --editable ./[base] 12 | RUN pip install -r ./aws-lambda/requirements.txt 13 | RUN pip install gunicorn 14 | RUN opentelemetry-bootstrap --action install 15 | 16 | COPY ./ ./ 17 | 18 | # Expose our access port 19 | EXPOSE ${DEV_PORT} 20 | 21 | # opentelemetry entrypoint 22 | ENV OTEL_SERVICE_NAME=example-rest-api--local 23 | ENV OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 24 | ENV OTEL_PYTHON_LOG_CORRELATION=true 25 | ENV OTEL_PYTHON_LOG_LEVEL=info 26 | # ENTRYPOINT opentelemetry-instrument 27 | 28 | # CMD \ 29 | # OTEL_PYTHON_LOG_CORRELATION=true \ 30 | # OTEL_PYTHON_LOG_LEVEL=info \ 31 | # opentelemetry-instrument uvicorn "example_rest_api.main:create_default_app" --reload --factory --workers 1 --host 0.0.0.0 --port ${DEV_PORT} 32 | 33 | CMD \ 34 | OTEL_PYTHON_LOG_CORRELATION=true \ 35 | OTEL_PYTHON_LOG_LEVEL=info \ 36 | opentelemetry-instrument -- gunicorn "example_rest_api.main:create_default_app()" \ 37 | --worker-class uvicorn.workers.UvicornWorker \ 38 | --workers 1 \ 39 | --bind 0.0.0.0:${DEV_PORT} \ 40 | --reload 41 | -------------------------------------------------------------------------------- /rest-api/Justfile: -------------------------------------------------------------------------------- 1 | install: 2 | pip install -e .[all] 3 | 4 | run-docker: 5 | docker-compose up 6 | 7 | run: 8 | provision_minecraft_server__state_machine__arn=abc \ 9 | uvicorn "example_rest_api.main:create_default_app" --reload --factory --workers 1 --host 0.0.0.0 --port 8000 10 | 11 | test: 12 | pytest tests/ \ 13 | --cov src/ \ 14 | --cov-report term-missing \ 15 | --cov-report html \ 16 | --cov-report xml \ 17 | --junitxml=./test-reports/junit.xml 18 | 19 | # equivalent of 'python -m doctest src/**/*.py docs/**/*.rst' 20 | python -m doctest $(find src -name "*.py") 21 | # python -m doctest $(find docs -name "*.rst") 22 | 23 | fetch-id-token: 24 | #!/usr/bin/env python3 25 | 26 | import requests 27 | 28 | CLIENT_ID = "3j7rkgete6i4erp2fhaulae3nt" 29 | CLIENT_SECRET = "197260bg7nl54nbiekalna1iq6e4sf44ihcqrqh8t00q8d2uhs1p" 30 | COGNITO_TOKEN_ENDPOINT = "https://minecraft-user-pool.auth.us-west-2.amazoncognito.com/oauth2/token" 31 | 32 | def get_access_token(): 33 | body = {"grant_type": "client_credentials", "scope": []} 34 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 35 | response = requests.post(url=COGNITO_TOKEN_ENDPOINT, data=body, auth=(CLIENT_ID, CLIENT_SECRET), headers=headers) 36 | return response.json() 37 | 38 | print(get_access_token()) 39 | -------------------------------------------------------------------------------- /rest-api/README.md: -------------------------------------------------------------------------------- 1 | hi.txt 2 | -------------------------------------------------------------------------------- /rest-api/aws-lambda/index.py: -------------------------------------------------------------------------------- 1 | """ 2 | Entrypoint for AWS Lambda. 3 | 4 | This file exposes a ``handler`` function which is imported and executed 5 | by AWS Lambda. The Mangum library converts the "event" object passed 6 | by lambda into a form that is usable by FastAPI. 7 | 8 | So Mangum is a FastAPI to Lambda converter. 9 | """ 10 | 11 | import json 12 | from logging import getLogger 13 | 14 | from example_rest_api.main import create_default_app 15 | from fastapi import FastAPI 16 | from mangum import Mangum 17 | 18 | LOGGER = getLogger(__name__) 19 | 20 | try: 21 | from aws_lambda_typing.context import Context 22 | from aws_lambda_typing.events import APIGatewayProxyEventV1 23 | except ImportError: 24 | ... 25 | 26 | 27 | print("Handler is initializing!!!") 28 | 29 | APP: FastAPI = create_default_app() 30 | 31 | 32 | def handler(event: "APIGatewayProxyEventV1", context: "Context"): 33 | LOGGER.info("Event %s", json.dumps(event)) 34 | print("Event %s" % json.dumps(event)) 35 | LOGGER.info("Context %s", context_to_json(context)) 36 | print("Context %s" % context_to_json(context)) 37 | mangum_app = Mangum(app=APP) 38 | return mangum_app(event, context) 39 | 40 | 41 | def context_to_json(context: "Context") -> str: 42 | """ 43 | Convert the context object into a JSON string. 44 | 45 | This is useful for logging the context object, which is not JSON serializable. 46 | """ 47 | return json.dumps( 48 | { 49 | "function_name": context.function_name, 50 | "function_version": context.function_version, 51 | "invoked_function_arn": context.invoked_function_arn, 52 | "memory_limit_in_mb": context.memory_limit_in_mb, 53 | "aws_request_id": context.aws_request_id, 54 | "log_group_name": context.log_group_name, 55 | "log_stream_name": context.log_stream_name, 56 | "identity": { 57 | "cognito_identity_id": context.identity.cognito_identity_id, 58 | "cognito_identity_pool_id": context.identity.cognito_identity_pool_id, 59 | } 60 | if context.identity 61 | else None, 62 | } 63 | ) 64 | 65 | 66 | def convert_dict_vals_to_strings(d: dict) -> dict: 67 | """ 68 | Convert all values in the dictionary to strings. 69 | 70 | This is useful for converting the context object, which is not JSON serializable. 71 | """ 72 | return {k: str(v) for k, v in d.items()} 73 | -------------------------------------------------------------------------------- /rest-api/aws-lambda/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | fastapi 3 | mangum 4 | 5 | opentelemetry-distro[otlp] 6 | opentelemetry-instrumentation-asgi 7 | 8 | # result of 'opentelemetry-bootstrap --action requirements' minus boto 9 | opentelemetry-instrumentation-aws-lambda 10 | opentelemetry-instrumentation-fastapi 11 | opentelemetry-instrumentation-logging 12 | opentelemetry-instrumentation-requests 13 | opentelemetry-instrumentation-urllib 14 | opentelemetry-instrumentation-urllib3 15 | opentelemetry-instrumentation-wsgi 16 | opentelemetry-propagator-aws-xray 17 | opentelemetry-sdk-extension-aws 18 | pydantic 19 | -------------------------------------------------------------------------------- /rest-api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | fastapi: 4 | build: . 5 | ports: 6 | - 8000:8000 7 | image: mlops-club/serverless-fastapi 8 | volumes: 9 | - ~/.aws:/root/.aws 10 | - .:/app 11 | environment: 12 | # AWS_PROFILE: mlops-club 13 | DEV_PORT: 8000 14 | ENVIRONMENT: development 15 | S3_BUCKET_NAME: serverless-fastapi-examplebucketdc717cf4-1b478jljaj606 16 | OTEL_SERVICE_NAME: example-rest-api--local 17 | # OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317 18 | OTEL_PYTHON_LOG_CORRELATION: "true" 19 | OTEL_PYTHON_LOG_LEVEL: info 20 | OTEL_PYTHON_ID_GENERATOR: xray 21 | OTEL_PROPAGATORS: xray 22 | # OTEL_TRACES_EXPORTER: console,otlp 23 | # OTEL_METRICS_EXPORTER: console 24 | 25 | ############################### 26 | # --- Local Observability --- # 27 | ############################### 28 | 29 | jaeger: 30 | image: jaegertracing/all-in-one:1.21 31 | ports: 32 | - 16686:16686 33 | - 14268:14268 34 | - 6831:6831/udp 35 | - 6832:6832/udp 36 | - 5778:5778 37 | - 5775:5775/udp 38 | - 9411:9411 39 | 40 | # install docker "loki" plugin 41 | # docker plugin install grafana/loki-docker-driver:latest --alias loki --grant-all-permissions 42 | -------------------------------------------------------------------------------- /rest-api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ "setuptools>=46.1.0", "wheel", "build", "docutils" ] 3 | 4 | [tool.isort] 5 | profile = "black" 6 | 7 | # from m import a, b -> from m import a; from m import b (on separate lines) 8 | # this is the same idea as putting list items or function arguments on separate lines 9 | # with commas at the end. It avoids merge conflicts in git. Some say it's easier to read, 10 | # although it does take up more vertical space, which some also say makes files harder 11 | # to read. 12 | force_single_line_imports = true 13 | 14 | 15 | # use autoflake with pyproject.toml with 16 | # 'autoflake --config pyproject.toml --in-place --remove-all-unused-imports --recursive .' 17 | # 18 | # note: autoflake won't let you set 'autoflake --config=path/to/pyproject.toml' https://github.com/PyCQA/autoflake/issues/193 19 | # right this moment. It supports setup.cfg, but I'd like to get rid of that entirely. 20 | # 21 | # Note: I don't really want to set this up for VS Code because I don't want imports to be 22 | # removed every time I "save". That gets annoying when you're just about to use an import. 23 | # Instead, constraining the 'autoflake' command to pre-commit is enough. 24 | # 25 | # Note: there's not an easy way to have .pre-commit-config.yaml be located in a different 26 | # folder than pyproject.toml and still execute 'autoflake' as inside the folder with 27 | # pyproject.toml. This means, (a) for a single-package repo, pyproject.toml + pre-commit works out of the box. 28 | # but (b) for a mono-repo/multi-package repo, these configurations would be better to place as CLI arguments 29 | # directly inside of the pyproject.toml. 30 | # 31 | # This was my attempt at the above ^^^: 32 | # - id: autoflake 33 | # # At the moment, 'autoflake --config=some/toml/file' does not work. It *can* pick up pyproject.toml 34 | # # if it's in the same directory as the command is executed from, so for now, we cd into the folder 35 | # # with the pyproject.toml. For our mono-repos, each pyproject file should have identical tool.* configs 36 | # # so any pyproject.toml will do. 37 | # # I used the workaround suggested by asotille here: https://github.com/pre-commit/pre-commit/issues/1110#issuecomment-518939116 38 | # entry: bash -c 'cd ./rest-api/ && autoflake "$@"' -- 39 | # language: system 40 | [tool.autoflake] 41 | # make changes to files instead of printing diffs 42 | # in_place = true 43 | # remove all unused imports (not just those from the standard library) 44 | remove_all_unused_imports = false 45 | # remove unused variables 46 | remove_unused_variable = [ "all" ] 47 | # exclude __init__.py when removing unused imports 48 | ignore_init_module_imports = true 49 | 50 | [tool.black] 51 | # When you use type annotations, your lines start to get long 52 | # black sets it to 88 and recommends "90-ish". Feel free to set it to your liking. 53 | # But try not to let it cause "holy wars". Leaving things to defaults is often good. 54 | # The main goal is usually consistency--not forcing everyone to do things your way. 55 | line-length = 120 56 | 57 | ################### 58 | # --- Flake 8 --- # 59 | ################### 60 | 61 | # 'pip install Flake8-pyproject' 62 | # adds a '--toml-config TOML_COMFIG' argument to the flake8 command, 63 | # so 'flake8 --toml-config path/to/pyproject.toml' works :), even in .vscode/settings.json! 64 | 65 | # there's a somewhat heated issue in GitHub about flake8 supporting pyproject.toml: 66 | # https://github.com/PyCQA/flake8/issues/234#issuecomment-1206730688 67 | 68 | # a lovely person took it upon themselves to write this plugin so flake8 could support pyproject.toml :) 69 | 70 | # note: comments like "# noqa: D413,D417 no need for this because of ..." will disable multiple flake8 warnings 71 | 72 | ################# 73 | # --- Radon --- # 74 | ################# 75 | 76 | # docs on the flake8-radon plugin: https://radon.readthedocs.io/en/latest/flake8.html 77 | # docs on how radon calculates cyclomatic complexity: https://radon.readthedocs.io/en/latest/intro.html 78 | 79 | # 'pip install radon' actually includes a flake8 plugin, there's no 'flake8-radon' 80 | 81 | # note: 'radon cc --show-complexity ' will show the "grade" (A, B, C, D, E) of each 82 | # function in a file, AND it shows the exact cyclomatic complexity score as calculated in 83 | # the radon docs above ^^^. That helps you understand how far you have to go in refactoring. 84 | 85 | # note: the CC is actually shown in the flake8 message in the parenthesis. So 86 | # flake8 shows you the --show-complexity output. 87 | 88 | ###################### 89 | # --- Pydocstyle --- # 90 | ###################### 91 | 92 | # 'pip install flake8-docstrings' 93 | # docs: https://pypi.org/project/flake8-docstrings/ 94 | 95 | # adds a '--docstring-convention google|numpy|pep257|all' argument to flake8 96 | 97 | [tool.flake8] 98 | # radon 99 | radon-max-cc = 10 100 | # this doesn't seem to do anything 101 | docstring-convention = "all" 102 | ignore = [ 103 | ###################### 104 | # --- Pydocstyle --- # 105 | ###################### 106 | # requires docstring in __init__, often this is trivial 107 | "D107", 108 | # Multi-line docstring summary should start at the first line 109 | "D212", 110 | 111 | ############################ 112 | # --- Flake8 (vanilla) --- # 113 | ############################ 114 | # line too long; black catches this 115 | "E501", 116 | # line break before binary operator; black causes violations of this 117 | "W503", 118 | # invalid escape sequence in string such as '\[' with rich; pylint catches this 119 | "W605", 120 | # (radon) function, class, module, etc. Example message: " is too complex ()" 121 | # "R701" 122 | ] 123 | 124 | ################## 125 | # --- Pylint --- # 126 | ################## 127 | 128 | # pylint 129 | # Confirmed: this works with VS Code. 130 | # supported file docs: https://pylint.readthedocs.io/en/latest/user_guide/usage/run.html 131 | # generate the entire file with 'pylint --generate-toml-config > pylint.toml' 132 | 133 | # comments like "# pylint: disable=invalid-name; this is an exception because ..." will disable a single pylint warning 134 | [tool.pylint."messages control"] 135 | # docs on "messages control" https://pylint.readthedocs.io/en/latest/user_guide/messages/message_control.html 136 | disable = [ 137 | # taken care of by black 138 | "line-too-long", 139 | # we have a pre-commit hook that strips trailing whitespace 140 | "trailing-whitespace", 141 | # pydocstyle catches this 142 | "missing-function-docstring", 143 | # infrastructure/containers/postgres/automatic-backup/backup_or_restore.py 144 | # currently runs on python 3.5.3 which does not support f-strings 145 | "consider-using-f-string", 146 | # pylint is currently executed by the pre-commit framework which 147 | # installs pylint in an isolated virtual environment--this means that 148 | # pylint cannot "see" any 3rd party libraries imported by code in rootski. 149 | # TODO - once we move away from "darker", execute pylint as a "local pre-commit hook" 150 | # so that it runs in the same environment as the rest of our code. 151 | "import-error", 152 | # most hand-written CDK classes (constructs/stacks) have no public methods 153 | "too-few-public-methods", 154 | # pytest fixtures rely on this pattern 155 | "redefined-outer-name", 156 | ] 157 | 158 | -------------------------------------------------------------------------------- /rest-api/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/requirements.txt -------------------------------------------------------------------------------- /rest-api/setup.cfg: -------------------------------------------------------------------------------- 1 | # ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | [metadata] 3 | name = serverless-fastapi 4 | author = 5 | author_email = 6 | home_page = 7 | description = 8 | # begin TODO, support both .rst and .md README files 9 | long_description = file: README.md 10 | long_description_content_type = text/markdown; charset=UTF-8 11 | # long_description_content_type = text/x-rst; charset=UTF-8 12 | # end TODO 13 | version = 0.0.0 14 | license = Proprietary 15 | # license_file = LICENSE.txt 16 | keywords = 17 | project_urls = 18 | Documentation = https://docs.rootski.io 19 | Source = https://github.com 20 | 21 | # https://pypi.python.org/pypi?%3Aaction=list_classifiers 22 | classifiers = 23 | Programming Language :: Python :: 3.7 24 | Programming Language :: Python :: 3.8 25 | Programming Language :: Python :: 3.9 26 | Programming Language :: Python :: 3.10 27 | 28 | [options] 29 | zip_safe = False 30 | package_dir= 31 | =src 32 | packages = find: 33 | include_package_data = True 34 | test_suite = tests/unit_tests 35 | python_requires = >= 3.7 36 | 37 | 38 | [options.packages.find] 39 | where = src 40 | 41 | exclude = 42 | tests 43 | 44 | [options.extras_require] 45 | base= 46 | importlib-metadata; python_version<"3.8" 47 | fastapi 48 | pydantic 49 | boto3 50 | 51 | opentelemetry= 52 | opentelemetry-distro 53 | opentelemetry-exporter-otlp 54 | opentelemetry-instrumentation-logging 55 | opentelemetry-instrumentation-fastapi 56 | 57 | test= 58 | pytest 59 | pytest-cov 60 | pytest-xdist 61 | moto[s3] 62 | httpx 63 | 64 | lambda = 65 | %(base)s 66 | mangum 67 | 68 | dev = 69 | boto3 70 | boto3-stubs[s3] 71 | mypy 72 | aws-lambda-typing 73 | uvicorn 74 | %(test)s 75 | %(lambda)s 76 | %(base)s 77 | 78 | all = 79 | %(dev)s 80 | 81 | 82 | [bdist_wheel] 83 | universal = true 84 | 85 | [check] 86 | metadata = true 87 | restructuredtext = true 88 | strict = true 89 | 90 | [sdist] 91 | formats = zip, gztar 92 | 93 | [tool:pytest] 94 | markers = 95 | foundational: Tests that must pass for subsequent tests to run. 96 | slow: Tests that take a long time to execute -------------------------------------------------------------------------------- /rest-api/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/src/example_rest_api/__init__.py -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/aws/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/src/example_rest_api/aws/__init__.py -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/aws/s3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | import boto3 6 | from example_rest_api.errors import FileNotFoundError 7 | 8 | try: 9 | from mypy_boto3_s3.client import S3Client 10 | from mypy_boto3_s3.type_defs import ( 11 | DeleteObjectOutputTypeDef, 12 | GetObjectOutputTypeDef, 13 | ListObjectsV2OutputTypeDef, 14 | PutObjectOutputTypeDef, 15 | ) 16 | except ImportError: 17 | print("Warning: boto3-stubs[s3] not installed") 18 | 19 | 20 | def upload_file_to_bucket( 21 | bucket_name: str, object_key: str, file_content: str, s3_client: Optional["S3Client"] = None 22 | ) -> "PutObjectOutputTypeDef": 23 | """Upload a file to an S3 bucket. 24 | 25 | :param bucket_name: The name of the S3 bucket. 26 | :param object_key: The name of the file to upload. 27 | :param file_content: The content of the file to upload. 28 | 29 | :raises FileNotFoundError: If the bucket does not exist. 30 | :raises boto3.exceptions.ClientError: Some other error. 31 | """ 32 | s3_client: "S3Client" = s3_client or boto3.client("s3") 33 | try: 34 | put_object_response: "PutObjectOutputTypeDef" = s3_client.put_object( 35 | Bucket=bucket_name, Key=object_key, Body=file_content 36 | ) 37 | except s3_client.exceptions.NoSuchBucket as err: 38 | raise FileNotFoundError(f"Could not upload file to bucket: {err}") from err 39 | 40 | return put_object_response 41 | 42 | 43 | def get_s3_object_contents(bucket_name: str, object_key: str, s3_client: Optional["S3Client"] = None) -> str: 44 | """Download and return the contents of an S3 object. 45 | 46 | :param bucket_name: The name of the S3 bucket. 47 | :param object_key: The name of the file to download. 48 | 49 | :raises FileNotFoundError: If the object does not exist. 50 | :raises boto3.exceptions.ClientError: Some other error. 51 | """ 52 | get_object_response: "GetObjectOutputTypeDef" = download_file_from_s3_bucket( 53 | bucket_name=bucket_name, object_key=object_key, s3_client=s3_client 54 | ) 55 | 56 | object_contents: str = get_object_response["Body"].read().decode("utf-8") 57 | return object_contents 58 | 59 | 60 | def download_file_from_s3_bucket( 61 | bucket_name: str, object_key: str, s3_client: Optional["S3Client"] = None 62 | ) -> "GetObjectOutputTypeDef": 63 | """Download a file from an S3 bucket. 64 | 65 | :param bucket_name: The name of the S3 bucket. 66 | :param object_key: The name of the file to download. 67 | 68 | :raises FileNotFoundError: If the object does not exist. 69 | :raises boto3.exceptions.ClientError: Some other error. 70 | """ 71 | s3_client: "S3Client" = s3_client or boto3.client("s3") 72 | 73 | try: 74 | download_object_response: "GetObjectOutputTypeDef" = s3_client.get_object( 75 | Bucket=bucket_name, Key=object_key 76 | ) 77 | except (s3_client.exceptions.NoSuchBucket, s3_client.exceptions.NoSuchKey) as err: 78 | raise FileNotFoundError(f"Could not fetch object: {err}") from err 79 | 80 | return download_object_response 81 | 82 | 83 | def delete_object_from_s3_bucket( 84 | bucket_name: str, object_key: str, s3_client: Optional["S3Client"] = None 85 | ) -> "DeleteObjectOutputTypeDef": 86 | """Delete an object from an S3 bucket. 87 | 88 | :param bucket_name: The name of the S3 bucket. 89 | :param object_key: The name of the object to delete. 90 | 91 | :raises FileNotFoundError: If the object does not exist. 92 | :raises boto3.exceptions.ClientError: Some other error. 93 | """ 94 | s3_client: "S3Client" = s3_client or boto3.client("s3") 95 | try: 96 | delete_object_response: "DeleteObjectOutputTypeDef" = s3_client.delete_object( 97 | Bucket=bucket_name, Key=object_key 98 | ) 99 | except (s3_client.exceptions.NoSuchBucket, s3_client.exceptions.NoSuchKey) as err: 100 | raise FileNotFoundError(f"No such object exists: {err}") from err 101 | 102 | return delete_object_response 103 | 104 | 105 | def list_object_paths_in_s3_bucket( 106 | bucket_name: str, object_prefix: str, s3_client: Optional["S3Client"] = None 107 | ) -> list[str]: 108 | """List all the object paths in an S3 bucket. 109 | 110 | :param bucket_name: The name of the S3 bucket. 111 | 112 | :raises FileNotFoundError: If the bucket does not exist. 113 | :raises boto3.exceptions.ClientError: Some other error. 114 | """ 115 | list_objects_response: "ListObjectsV2OutputTypeDef" = list_objects_in_s3_bucket( 116 | bucket_name=bucket_name, object_prefix=object_prefix, s3_client=s3_client 117 | ) 118 | 119 | if "Contents" in list_objects_response.keys(): 120 | object_paths: list[str] = [obj["Key"] for obj in list_objects_response["Contents"]] 121 | else: 122 | object_paths = [] 123 | 124 | return object_paths 125 | 126 | 127 | def list_objects_in_s3_bucket( 128 | bucket_name: str, object_prefix: str, s3_client: Optional["S3Client"] = None 129 | ) -> "ListObjectsV2OutputTypeDef": 130 | """List all the objects in an S3 bucket. 131 | 132 | :param bucket_name: The name of the S3 bucket. 133 | 134 | :raises FileNotFoundError: If the bucket does not exist. 135 | :raises boto3.exceptions.ClientError: Some other error. 136 | """ 137 | s3_client: "S3Client" = s3_client or boto3.client("s3") 138 | try: 139 | list_objects_response: "ListObjectsV2OutputTypeDef" = s3_client.list_objects_v2( 140 | Bucket=bucket_name, Prefix=object_prefix 141 | ) 142 | except s3_client.exceptions.NoSuchBucket as err: 143 | raise FileNotFoundError(f"Could not list objects in bucket: {err}") from err 144 | 145 | return list_objects_response 146 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/errors.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Dict, 3 | Optional, 4 | Union, 5 | ) 6 | 7 | from fastapi import ( 8 | HTTPException, 9 | ) 10 | from starlette.status import ( 11 | HTTP_404_NOT_FOUND, 12 | ) 13 | 14 | aaaa_aaaa_aaaa_aaaa = 1 15 | 16 | hhhhlkjkljlkjlkjljlkjljlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjljljlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkjlkasdfasdfasdfasdfasdfasdfasdfasdasdfsadfasdfasdfasdf = ( 17 | 1 18 | ) 19 | 20 | a = 1 21 | b = 2 22 | c = 3 23 | d = 4 24 | e = 5 25 | f = 6 26 | g = 7 27 | h = 8 28 | i = 9 29 | j = 10 30 | 31 | 32 | def func(): 33 | for i in range(a): 34 | if a + b > c and b + c > d and c + d > e and d + e > f and e + f > g and f + g > h and g + h > i: 35 | if b + c > d and c + d > e and d + e > f and e + f > g and f + g > h and g + h > i: 36 | print("hi") 37 | 38 | 39 | class Error(Exception): 40 | """Parent class for all exceptions raised by this codebase.""" 41 | 42 | 43 | class FileNotFoundError(Error): 44 | """Error returned by internal functions when a file cannot be found.""" 45 | 46 | @staticmethod 47 | def make_message( 48 | path: str, 49 | ) -> str: 50 | """Return a message for the exception.""" 51 | return f"Could not find file: {path}" 52 | 53 | @staticmethod 54 | def make_http_exception( 55 | path: str, 56 | ) -> HTTPException: 57 | """ 58 | Return an HTTPException for the exception. 59 | 60 | A-hoy there! 61 | 62 | Parameters 63 | ---------- 64 | 65 | paths: str 66 | The path to the file that could not be found. 67 | 68 | :param paths: hi 69 | """ # noqa: D413,D417 70 | return HTTPException( 71 | status_code=HTTP_404_NOT_FOUND, 72 | detail=FileNotFoundError.make_message(path), 73 | ) 74 | 75 | @staticmethod 76 | def make_http_exception_dict( 77 | path: str, 78 | ) -> Dict[str, str]: 79 | """Return an HTTPException for the exception.""" 80 | return {"detail": FileNotFoundError.make_message(path)} 81 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | A FastAPI app for the Minecraft API. 3 | 4 | This app will have a /status endpoint which will return 200 if the server is alive. 5 | It will also have a /deploy endpoint which will start the server if it is not already running. 6 | It will have a /destroy endpoint which will stop the server if it is running. 7 | 8 | The deploy and destroy endpoints will be responsible for creating a JSON message to post 9 | to a AWS Step Function with a single variable: "command" which will be either "deploy" or "destroy". 10 | The Step Function will then be responsible for starting and stopping the server. 11 | """ 12 | 13 | 14 | import logging 15 | from textwrap import dedent 16 | from typing import List, Optional 17 | 18 | from example_rest_api.middlewares import add_response_time_header, log_request 19 | from example_rest_api.routes import FILES_ROUTER 20 | from example_rest_api.schemas import APIServices 21 | from example_rest_api.services import FileManagerService 22 | from example_rest_api.settings import Settings 23 | from fastapi import FastAPI 24 | from fastapi.middleware.cors import CORSMiddleware 25 | 26 | LOGGER = logging.getLogger(__name__) 27 | 28 | LOGGER.error("Initializing FastAPI app...") 29 | 30 | 31 | def create_app( 32 | settings: Optional[Settings] = None, 33 | ) -> FastAPI: 34 | """Return a FastAPI instance, configured to handle requests.""" 35 | 36 | LOGGER.error("Initializing FastAPI app...") 37 | 38 | if not settings: 39 | settings = Settings() 40 | 41 | app = FastAPI( 42 | title="Serverless FastAPI", 43 | description=dedent( 44 | """\ 45 | Production-ready example of best practices for hosting a FastAPI based serverless REST API in AWS. 46 | 47 | This sample API exposes an interface for managing files and their contents. 48 | 49 | [![Example badge](https://img.shields.io/badge/Example-Badge%20Link-blue.svg)](https://ericriddoch.info) 50 | 51 | Same badge with square theme 52 | 53 | [![Example badge](https://img.shields.io/badge/Example-Badge%20Link-blue.svg?style=flat-square)](https://ericriddoch.info) 54 | [![Example badge](https://img.shields.io/badge/Example-Badge%20Link-blue.svg?style=for-the-badge)](https://ericriddoch.info) 55 | """ 56 | ), 57 | version="1.0.0", 58 | docs_url="/", 59 | redoc_url="/redoc", 60 | root_path=settings.root_path, 61 | ) 62 | 63 | # we can put arbitrary attributes onto app.state and access them from the routes 64 | app.state.settings = settings 65 | app.state.services = APIServices(file_manager=FileManagerService.from_settings(settings)) 66 | 67 | # configure startup behavior: initialize services on startup 68 | @app.on_event("startup") 69 | async def on_startup(): 70 | """Initialize each service.""" 71 | app_services: APIServices = app.state.services 72 | app_services.file_manager.init() 73 | 74 | # add routes 75 | app.include_router(FILES_ROUTER, tags=["Files"]) 76 | app.get("/healthcheck", tags=["Admin"])(ping_this_api) 77 | 78 | app.middleware("http")(add_response_time_header) 79 | app.middleware("http")(log_request) 80 | 81 | configure_cors(allowed_origins=settings.allowed_cors_origins, app=app) 82 | 83 | return app 84 | 85 | 86 | async def ping_this_api(): 87 | """Return 200 to demonstrate that this REST API is reachable and can execute.""" 88 | return "200 OK" 89 | 90 | 91 | def configure_cors(allowed_origins: List[str], app: FastAPI): 92 | """ 93 | Configure CORS responses as a FastAPI middleware. 94 | 95 | Add authorized CORS origins (add these origins to response headers to 96 | enable frontends at these origins to receive requests from this API). 97 | """ 98 | app.add_middleware( 99 | CORSMiddleware, 100 | allow_origins=allowed_origins, 101 | allow_credentials=True, 102 | allow_methods=["*"], 103 | allow_headers=["*"], 104 | ) 105 | 106 | 107 | def create_default_app() -> FastAPI: 108 | """ 109 | Return an initialized FastAPI object using configuration from environment variables. 110 | 111 | This is a factory method that can be used by WSGI/ASGI runners like gunicorn and uvicorn. 112 | It is also useful for providing an application invokable by AWS Lambda. 113 | """ 114 | settings = Settings() 115 | return create_app(settings=settings) 116 | 117 | 118 | if __name__ == "__main__": 119 | import uvicorn 120 | 121 | config = Settings() 122 | app = create_app(settings=config) 123 | uvicorn.run(app, host="0.0.0.0", port=config.dev_port) 124 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/middlewares.py: -------------------------------------------------------------------------------- 1 | """ 2 | Middleware that logs basic request details to the console. 3 | """ 4 | 5 | import json 6 | from logging import getLogger 7 | from timeit import default_timer 8 | 9 | from fastapi import Request, Response 10 | 11 | LOGGER = getLogger(__name__) 12 | 13 | # fastapi middleware 14 | async def log_request(request: Request, call_next) -> Response: 15 | """ 16 | Log basic request details to the console. 17 | 18 | Use structured logging to make it easier to parse the logs. 19 | 20 | Details include: 21 | - HTTP method 22 | - URL host 23 | - URL path 24 | - URL query parameters (or empty JSON object) 25 | - HTTP headers 26 | - HTTP request body (or empty JSON object) 27 | - Handler duration 28 | """ 29 | req_body = {} 30 | try: 31 | req_body = await request.json() 32 | except json.decoder.JSONDecodeError: 33 | ... 34 | 35 | LOGGER.info( 36 | "Request details: %s", 37 | json.dumps( 38 | { 39 | "method": request.method, 40 | "host": request.url.hostname, 41 | "path": request.url.path, 42 | "query_params": dict(request.query_params.items()), 43 | "headers": dict(request.headers), 44 | "body": req_body, 45 | } 46 | ), 47 | ) 48 | 49 | response: Response = await call_next(request) 50 | 51 | LOGGER.info( 52 | "Response details: %s", 53 | json.dumps( 54 | { 55 | "status_code": response.status_code, 56 | "headers": dict(response.headers), 57 | "body": getattr(response, "body", None), 58 | } 59 | ), 60 | ) 61 | 62 | return response 63 | 64 | 65 | async def add_response_time_header(request: Request, call_next) -> Response: 66 | """ 67 | Track the duration of a request. 68 | 69 | Add the duration to the response headers. 70 | """ 71 | start_time = default_timer() 72 | response: Response = await call_next(request) 73 | end_time = default_timer() 74 | response.headers["X-Response-Time"] = f"{(end_time - start_time) * 1000:.2f} ms" 75 | return response 76 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """All routes exposable by the Minecraft Platform REST API.""" 2 | 3 | from .files import ROUTER as FILES_ROUTER 4 | 5 | __all__ = ["FILES_ROUTER"] 6 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/routes/docs.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Union 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ExampleResponse(BaseModel): 7 | title: str 8 | body: Dict[str, Union[str, Any]] # jsonable dict 9 | 10 | 11 | class DuplicateExampleTitle(Exception): 12 | """Raised when two examples of the same content type have the same title.""" 13 | 14 | 15 | def make_openapi_example_responses_obj(example_responses: Dict[str, List[ExampleResponse]]) -> dict: 16 | """ 17 | Return a dictionary used to document the possible responses 18 | for a single HTTP status code. 19 | """ 20 | swagger_example_responses_obj = {} 21 | for content_type, examples in example_responses.items(): 22 | swagger_example_responses_obj = deeply_merge_dicts( 23 | swagger_example_responses_obj, 24 | make_apidocs_responses_obj_for_content_type(content_type, examples), 25 | ) 26 | return swagger_example_responses_obj 27 | 28 | 29 | def make_apidocs_responses_obj_for_content_type(content_type: str, examples: List[ExampleResponse]): 30 | """ 31 | Return a dictionary used to document the possible responses 32 | for a single HTTP status code. 33 | """ 34 | swagger_example_responses_obj = { 35 | "content": { 36 | content_type: { 37 | "examples": { 38 | # "Invalid breakdown": { 39 | # "value": { 40 | # "detail": PARTS_DONT_SUM_TO_WHOLE_WORD_MSG.format( 41 | # submitted_breakdown="при-каз-ывать", word="приказать" 42 | # ) 43 | # } 44 | # } 45 | } 46 | } 47 | } 48 | } 49 | for example_response in examples: 50 | examples = swagger_example_responses_obj["content"][content_type]["examples"] 51 | if example_response.title in examples.keys(): 52 | raise DuplicateExampleTitle( 53 | f"Example response title {example_response.title} appears twice for this endpoint." 54 | ) 55 | examples[example_response.title] = { 56 | "value": example_response.body, 57 | } 58 | 59 | return swagger_example_responses_obj 60 | 61 | 62 | def deeply_merge_dicts(dict1: dict, dict2: dict) -> dict: 63 | """Return the dictionary resulting from recursively merging the two input dicts.""" 64 | merged_dict = dict1.copy() 65 | for key, value in dict2.items(): 66 | if key in merged_dict and isinstance(merged_dict[key], dict) and isinstance(value, dict): 67 | merged_dict[key] = deeply_merge_dicts(merged_dict[key], value) 68 | else: 69 | merged_dict[key] = value 70 | return merged_dict 71 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/routes/files.py: -------------------------------------------------------------------------------- 1 | """Module defines endpoints for server information & acitons.""" 2 | 3 | from logging import getLogger 4 | from typing import List, Union 5 | 6 | from example_rest_api import schemas 7 | from example_rest_api.errors import FileNotFoundError 8 | from example_rest_api.routes.docs import ExampleResponse, make_apidocs_responses_obj_for_content_type 9 | from example_rest_api.schemas.files import ListFilesResponse, PostFileResponse 10 | from fastapi import APIRouter, Query, Request 11 | from fastapi.responses import PlainTextResponse 12 | from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND 13 | 14 | LOGGER = getLogger(__name__) 15 | 16 | ROUTER = APIRouter() 17 | 18 | 19 | @ROUTER.get( 20 | "/files/{path}", 21 | response_class=PlainTextResponse, 22 | responses={ 23 | HTTP_404_NOT_FOUND: make_apidocs_responses_obj_for_content_type( 24 | content_type="text/plain", 25 | examples=[ 26 | ExampleResponse( 27 | title="File not found", 28 | body=FileNotFoundError.make_http_exception_dict("path/to/file.txt"), 29 | ) 30 | ], 31 | ), 32 | }, 33 | ) 34 | def get_file(path: str, request: Request): 35 | """Try to return the contents of a file.""" 36 | services = schemas.APIServices.from_request(request) 37 | try: 38 | LOGGER.info('Attempting to retrieve contents of file at path: "%s"', path) 39 | contents: str = services.file_manager.read_file(path) 40 | return PlainTextResponse(content=contents, status_code=HTTP_200_OK) 41 | except FileNotFoundError as err: 42 | LOGGER.exception("File not found: %s", path) 43 | raise FileNotFoundError.make_http_exception(path) from err 44 | 45 | 46 | @ROUTER.post( 47 | "/files/{path}", 48 | response_model=PostFileResponse, 49 | responses={ 50 | HTTP_200_OK: make_apidocs_responses_obj_for_content_type( 51 | content_type="application/json", 52 | examples=[ 53 | ExampleResponse( 54 | title="Success", 55 | body=PostFileResponse(path="path/to/file.txt").dict(), 56 | ) 57 | ], 58 | ), 59 | }, 60 | ) 61 | def create_file(path: str, content: str, request: Request): 62 | """Create or overwrite a new file.""" 63 | LOGGER.info('Attempting to write %s bytes to file at path: "%s"', len(content), path) 64 | services = schemas.APIServices.from_request(request) 65 | services.file_manager.write_file(path, content) 66 | return PostFileResponse(path=path) 67 | 68 | 69 | @ROUTER.delete( 70 | "/files/{path}", 71 | response_model=PostFileResponse, 72 | responses={ 73 | HTTP_200_OK: make_apidocs_responses_obj_for_content_type( 74 | content_type="application/json", 75 | examples=[ 76 | ExampleResponse( 77 | title="Success", 78 | body=PostFileResponse(path="path/to/file.txt").dict(), 79 | ) 80 | ], 81 | ), 82 | }, 83 | ) 84 | def delete_file(path: str, request: Request): 85 | """Delete a file.""" 86 | services = schemas.APIServices.from_request(request) 87 | LOGGER.info('Attempting to delete file at path: "%s"', path) 88 | services.file_manager.delete_file(path) 89 | return PostFileResponse(path=path) 90 | 91 | 92 | @ROUTER.get( 93 | "/files/", 94 | response_model=ListFilesResponse, 95 | responses={ 96 | HTTP_200_OK: make_apidocs_responses_obj_for_content_type( 97 | content_type="application/json", 98 | examples=[ 99 | ExampleResponse( 100 | title="Success", 101 | body=ListFilesResponse(files=["path/to/file1.txt", "path/to/file2.txt"]).dict(), 102 | ) 103 | ], 104 | ), 105 | }, 106 | ) 107 | def list_files(request: Request, directory_path: Union[str, None] = Query(default=None)): 108 | """List the files in a directory in S3.""" 109 | services = schemas.APIServices.from_request(request) 110 | file_paths: List[str] = services.file_manager.list_files(directory_path) 111 | return ListFilesResponse(files=file_paths) 112 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from .services import APIServices 2 | 3 | __all__ = ["APIServices"] 4 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/schemas/files.py: -------------------------------------------------------------------------------- 1 | """Request and response models for the ``files`` route.""" 2 | 3 | from typing import List 4 | 5 | from pydantic import BaseModel 6 | 7 | 8 | class PostFileResponse(BaseModel): 9 | """Response for creating a file.""" 10 | 11 | path: str 12 | 13 | 14 | class ListFilesResponse(BaseModel): 15 | """Response for listing files.""" 16 | 17 | files: List[str] 18 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/schemas/services.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Type 3 | 4 | from example_rest_api.services import FileManagerService 5 | from fastapi import Request 6 | 7 | 8 | @dataclass 9 | class APIServices: 10 | """ 11 | Container for all ``Service``s used by the running application. 12 | 13 | The ``Service`` abstraction should be used for any code that 14 | makes calls over the network to services external to this API. 15 | """ 16 | 17 | file_manager: FileManagerService 18 | 19 | @classmethod 20 | def from_request(cls: Type["APIServices"], request: Request) -> "APIServices": 21 | """Return an instance of ``APIServices`` from the request object.""" 22 | return request.app.state.services 23 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_manager import FileManagerService 2 | 3 | __all__ = ["FileManagerService"] 4 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/services/file_manager.py: -------------------------------------------------------------------------------- 1 | """Module defines a classe that provisions minecraft server.""" 2 | 3 | from __future__ import annotations 4 | 5 | from pathlib import Path 6 | from typing import List, Optional 7 | 8 | import boto3 9 | from example_rest_api.aws.s3 import ( 10 | delete_object_from_s3_bucket, 11 | get_s3_object_contents, 12 | list_object_paths_in_s3_bucket, 13 | upload_file_to_bucket, 14 | ) 15 | from example_rest_api.services.service import IService 16 | from example_rest_api.settings import Settings 17 | 18 | try: 19 | from mypy_boto3_s3 import S3Client 20 | except ImportError: 21 | ... 22 | 23 | 24 | class FileManagerService(IService): 25 | """Class that manages file storage.""" 26 | 27 | def __init__(self, s3_bucket_name: str, s3_object_prefix: str): 28 | """Initialize the FileManager.""" 29 | self._s3_bucket_name: str = s3_bucket_name 30 | self._s3_object_prefix: str = s3_object_prefix 31 | 32 | def init(self) -> None: 33 | """Initialize the FileManager.""" 34 | self._s3_client: Optional["S3Client"] = boto3.client("s3") 35 | 36 | def write_file(self, path: str, content: str) -> str: 37 | """Write a file to S3.""" 38 | path: str = make_s3_path(s3_object_prefix=self._s3_object_prefix, path=path) 39 | upload_file_to_bucket( 40 | bucket_name=self._s3_bucket_name, 41 | object_key=path, 42 | file_content=content, 43 | s3_client=self._s3_client, 44 | ) 45 | return path 46 | 47 | def read_file(self, path: str) -> str: 48 | """Return the contents of a file.""" 49 | return get_s3_object_contents( 50 | bucket_name=self._s3_bucket_name, 51 | object_key=make_s3_path(s3_object_prefix=self._s3_object_prefix, path=path), 52 | s3_client=self._s3_client, 53 | ) 54 | 55 | def list_files(self, directory_path: Optional[str] = None) -> List[str]: 56 | """List the files in a directory.""" 57 | directory_path = directory_path or "" 58 | directory_path: str = make_s3_path(s3_object_prefix=self._s3_object_prefix, path=directory_path) 59 | object_fpaths: List[str] = list_object_paths_in_s3_bucket( 60 | bucket_name=self._s3_bucket_name, 61 | object_prefix=directory_path, 62 | s3_client=self._s3_client, 63 | ) 64 | object_fpaths_without_prefix: List[str] = strip_prefix_from_list_items( 65 | strings=object_fpaths, prefix=self._s3_object_prefix 66 | ) 67 | return object_fpaths_without_prefix 68 | 69 | def delete_file(self, path: str) -> str: 70 | """Delete a file from S3.""" 71 | path: str = make_s3_path(s3_object_prefix=self._s3_object_prefix, path=path) 72 | delete_object_from_s3_bucket( 73 | bucket_name=self._s3_bucket_name, 74 | object_key=path, 75 | s3_client=self._s3_client, 76 | ) 77 | return path 78 | 79 | @classmethod 80 | def from_settings(cls, settings: Settings) -> IService: 81 | return FileManagerService( 82 | s3_bucket_name=settings.s3_bucket_name, s3_object_prefix=settings.s3_object_prefix 83 | ) 84 | 85 | 86 | def make_s3_path(s3_object_prefix: str, path: str) -> str: 87 | """Return the full path to an object in S3.""" 88 | path: Path = Path(s3_object_prefix) / path 89 | return str(path.resolve()) 90 | 91 | 92 | def strip_prefix_from_list_items(strings: List[str], prefix: str) -> List[str]: 93 | """Remove a prefix from a list of strings.""" 94 | return [string.replace(prefix, "") for string in strings] 95 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/services/service.py: -------------------------------------------------------------------------------- 1 | """This is a docstring.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Protocol 6 | 7 | from example_rest_api.settings import Settings 8 | 9 | 10 | class IService(Protocol): 11 | """An interface for interacting with external services over the network.""" 12 | 13 | def init(self) -> None: 14 | """ 15 | Initialize the service. 16 | 17 | This method is called during the "on_startup" lifecycle event of the FastAPI app. 18 | """ 19 | 20 | @classmethod 21 | def from_settings(cls, settings: Settings) -> IService: 22 | """ 23 | Create a ``Service`` from the API ``Settings``. 24 | 25 | As a rule, try only to take minimum number of attributes from the ``settings`` object 26 | needed for this class to operate, then pass them to the __init__ method of the child class. 27 | 28 | Thusly, this factory method follows the "principle of least knowledge" and won't 29 | break if unnecessary attributes disappear or change. 30 | """ 31 | -------------------------------------------------------------------------------- /rest-api/src/example_rest_api/settings.py: -------------------------------------------------------------------------------- 1 | """Class for managing the global application settings.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Dict, List, Literal, Optional 6 | 7 | from pydantic import BaseSettings, validator 8 | 9 | 10 | class Settings(BaseSettings): 11 | """ 12 | Global settings for an instance of the Minecraft Platform Backend REST API (this project). 13 | 14 | By inheriting from BaseSettings, all attributes of this class are read from environment variables. 15 | 16 | Storing application configuration in the environment is a best practice set forth in the 17 | 12-factor app methodology; reference: https://12factor.net/config 18 | """ 19 | 20 | class Config: 21 | """ 22 | Pydantic-specific settings that determine the behavior of the Settings class. 23 | 24 | Read about the various options settable in this Config class here: 25 | https://docs.pydantic.dev/usage/settings/ 26 | 27 | Also here: 28 | https://docs.pydantic.dev/usage/model_config/ 29 | """ 30 | 31 | # causes attributes of Settings to be read from environment variables; ignoring case 32 | case_sensitive = False 33 | 34 | # make all attributes of Settings immutable 35 | frozen = True 36 | 37 | root_path: Optional[str] = None 38 | """Prefix for all API routes. May be necessary when running behind a reverse proxy.""" 39 | 40 | s3_bucket_name: str 41 | """Name of the S3 bucket where files are stored.""" 42 | 43 | s3_object_prefix: str = "" 44 | """Prefix for all S3 objects.""" 45 | 46 | environment: Literal["development", "production"] = "development" 47 | 48 | frontend_cors_url: Optional[str] = None 49 | """ 50 | The https:// url from which the frontend site is reachable. 51 | The backend REST API must include this URL in all response headers 52 | or else browsers will block the frontend from recieving API responses. 53 | """ 54 | 55 | dev_port: int = 8000 56 | """Port on which the FastAPI server will run in development mode on a developer's machine.""" 57 | 58 | frontend_dev_port: int = 3000 59 | """Port used for the frontend development server.""" 60 | 61 | # pylint: disable=no-self-argument 62 | @validator("frontend_cors_url", pre=True) # noqa: R0201 63 | def validate_frontend_cors_url(cls, frontend_cors_url: Optional[str], values: Dict[str, Any]) -> str: 64 | """Validate frontend_cors_url.""" 65 | if values["environment"] == "production" and not frontend_cors_url: 66 | raise ValueError("frontend_cors_url must be set when environment is production") 67 | return frontend_cors_url 68 | 69 | @property 70 | def allowed_cors_origins(self) -> List[str]: 71 | """Return a list of allowed CORS origins.""" 72 | origin_for_local_development = f"http://localhost:{self.frontend_dev_port}" 73 | return [origin_for_local_development, self.frontend_cors_url] 74 | -------------------------------------------------------------------------------- /rest-api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/tests/__init__.py -------------------------------------------------------------------------------- /rest-api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Reusable fixtures for the tests.""" 2 | import os # noqa: D100 C0114 3 | import sys 4 | from pathlib import Path 5 | 6 | import pytest 7 | from aws_cdk import Environment 8 | 9 | THIS_DIR = Path(__file__).parent 10 | sys.path.append(str(THIS_DIR)) 11 | 12 | 13 | @pytest.fixture(scope="module") 14 | def dev_env(): # noqa: D103 15 | return Environment(account=os.environ["AWS_ACCOUNT_ID"], region=os.environ["AWS_REGION"]) 16 | 17 | 18 | pytest_plugins = ["fixtures.settings", "fixtures.state_machine"] 19 | -------------------------------------------------------------------------------- /rest-api/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /rest-api/tests/fixtures/settings.py: -------------------------------------------------------------------------------- 1 | """Settings instance for tests.""" 2 | 3 | import pytest 4 | from minecraft_paas_api.settings import Settings 5 | 6 | 7 | @pytest.fixture 8 | def settings(state_machine_arn: str) -> Settings: 9 | """Fixture to create a settings object.""" 10 | return Settings( 11 | provision_minecraft_server__state_machine__arn=state_machine_arn, 12 | frontend_cors_url="https://awesome-minecraft-platform-domain.io", 13 | environment="development", 14 | ) 15 | -------------------------------------------------------------------------------- /rest-api/tests/fixtures/state_machine.py: -------------------------------------------------------------------------------- 1 | """Mocked state machine for tests.""" 2 | 3 | import json 4 | 5 | import boto3 6 | import pytest 7 | from moto import mock_stepfunctions 8 | from mypy_boto3_stepfunctions import SFNClient 9 | 10 | STATE_MACHINE_DEFINITION = { 11 | "StartAt": "awscdk-minecraftProvisionMcStateMachine-ChooseCdkDeployOrDestroy", 12 | "States": { 13 | "awscdk-minecraftProvisionMcStateMachine-ChooseCdkDeployOrDestroy": { 14 | "Type": "Choice", 15 | "Choices": [ 16 | { 17 | "Variable": "$.command", 18 | "StringEquals": "deploy", 19 | "Next": "awscdk-minecraftProvisionMcStateMachineCdkDeployMcServerBatchJob", 20 | }, 21 | { 22 | "Variable": "$.command", 23 | "StringEquals": "destroy", 24 | "Next": "awscdk-minecraftProvisionMcStateMachineCdkDestroyMcServerBatchJob", 25 | }, 26 | ], 27 | }, 28 | "awscdk-minecraftProvisionMcStateMachineCdkDeployMcServerBatchJob": { 29 | "End": True, 30 | "Type": "Task", 31 | "Resource": "arn:aws:states:::batch:submitJob.sync", 32 | "Parameters": { 33 | "JobDefinition": "arn:aws:batch:us-west-2:630013828440:job-definition/McDeployJobDefinitionCd-8ea9140b7faf087:1", 34 | "JobName": "awscdk-minecraftProvisionMcStateMachineDeployMinecraftServer", 35 | "JobQueue": "arn:aws:batch:us-west-2:630013828440:job-queue/CdkDockerBatchEnvCdkDock-KbAffuL47Ws4y1Jt", 36 | "ContainerOverrides": { 37 | "Command": ["cdk", "deploy", "--app", "'python3 /app/app.py'", "--require-approval=never"] 38 | }, 39 | }, 40 | }, 41 | "awscdk-minecraftProvisionMcStateMachineCdkDestroyMcServerBatchJob": { 42 | "End": True, 43 | "Type": "Task", 44 | "Resource": "arn:aws:states:::batch:submitJob.sync", 45 | "Parameters": { 46 | "JobDefinition": "arn:aws:batch:us-west-2:630013828440:job-definition/McDeployJobDefinitionCd-8ea9140b7faf087:1", 47 | "JobName": "awscdk-minecraftProvisionMcStateMachineDestroyMinecraftServer", 48 | "JobQueue": "arn:aws:batch:us-west-2:630013828440:job-queue/CdkDockerBatchEnvCdkDock-KbAffuL47Ws4y1Jt", 49 | "ContainerOverrides": { 50 | "Command": ["cdk", "destroy", "--app", "'python3 /app/app.py'", "--force"] 51 | }, 52 | }, 53 | }, 54 | }, 55 | } 56 | 57 | 58 | @pytest.fixture 59 | def state_machine_arn() -> str: 60 | """Create a step functions state machine with boto; return its ARN.""" 61 | with mock_stepfunctions(): 62 | sfn_client: SFNClient = boto3.client("stepfunctions") 63 | create_state_machine: dict = sfn_client.create_state_machine( 64 | name="provision-minecraft-server", 65 | definition=json.dumps(STATE_MACHINE_DEFINITION), 66 | roleArn="arn:aws:iam::123456789012:role/service-role/StepFunctions-ExecutionRole-us-east-1", 67 | ) 68 | 69 | yield create_state_machine["stateMachineArn"] 70 | -------------------------------------------------------------------------------- /rest-api/tests/functional_tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Functional tests.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | tests_path = Path(sys.path[0]) 7 | sys.path.append(str(tests_path.parent)) 8 | -------------------------------------------------------------------------------- /rest-api/tests/functional_tests/test_descriptor_routes.py: -------------------------------------------------------------------------------- 1 | """Test the routes related to deployment.""" 2 | 3 | from datetime import datetime 4 | 5 | import pytest 6 | from fastapi import FastAPI, Response 7 | from fastapi.testclient import TestClient 8 | from minecraft_paas_api.main import create_app 9 | from minecraft_paas_api.settings import Settings 10 | from starlette.status import HTTP_200_OK, HTTP_404_NOT_FOUND 11 | 12 | 13 | def parse_execution_time(execution_time: str) -> datetime: 14 | """Parse an AWS-formatted datetime string to a datetime object.""" 15 | return datetime.strptime(execution_time, "%Y-%m-%dT%H:%M:%S.%f%z") 16 | 17 | 18 | @pytest.fixture() 19 | def test_client(settings: Settings) -> TestClient: 20 | """Prepare a FastAPI test client which allows us to execute our endpoints with a ``requests``-like interface.""" 21 | minecraft_pass_api: FastAPI = create_app(settings=settings) 22 | with TestClient(minecraft_pass_api) as client: 23 | yield client 24 | 25 | 26 | def test_deploy(test_client: TestClient): 27 | """Test that the deploy endpoint can execute returns a 200 response.""" 28 | response: Response = test_client.get("/deploy") 29 | assert response.status_code == HTTP_200_OK 30 | assert response.json() == "Success!" 31 | 32 | 33 | def test_get_latest_execution(test_client: TestClient): 34 | """ 35 | Test that the latest-execution endpoint can describe executions. 36 | 37 | Verify that the described execution is indeed the most recent one. 38 | """ 39 | # no executions have happened yet 40 | response: Response = test_client.get("/latest-execution") 41 | assert response.status_code == HTTP_404_NOT_FOUND 42 | 43 | # trigger and describe the first execution 44 | test_client.get("/deploy") 45 | response: Response = test_client.get("/latest-execution") 46 | assert response.status_code == HTTP_200_OK 47 | execution_start_time_1: datetime = parse_execution_time(response.json()["startDate"]) 48 | 49 | # trigger and describe the second execution 50 | test_client.get("/deploy") 51 | response: Response = test_client.get("/latest-execution") 52 | assert response.status_code == HTTP_200_OK 53 | execution_start_time_2 = parse_execution_time(response.json()["startDate"]) 54 | 55 | # the second execution should have started after the first 56 | assert execution_start_time_1 < execution_start_time_2 57 | -------------------------------------------------------------------------------- /rest-api/tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | # Added project to the system path to allow importing between test-categories 7 | tests_path = Path(sys.path[0]) 8 | 9 | sys.path.append(str(tests_path.parent)) 10 | -------------------------------------------------------------------------------- /rest-api/tests/unit_tests/test_settings.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mlops-club/serverless-fastapi/9324a5a614fb6f5fd26edce6cabbb54de68d4cba/rest-api/tests/unit_tests/test_settings.py -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | // ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". 2 | { 3 | "restructuredtext.confPath": "", 4 | "autoDocstring.customTemplatePath": "./.vscode/python-docstring-template.mustache", 5 | "python.linting.pylintEnabled": true, 6 | "python.linting.pylintArgs": [ 7 | "--rcfile=./linting/.pylintrc" 8 | ], 9 | "python.formatting.provider": "black", 10 | "python.formatting.blackArgs": [ 11 | "--line-length=112" 12 | ], 13 | "python.linting.flake8Enabled": true, 14 | "python.linting.flake8Args": [ 15 | "--config==./linting/.flake8", 16 | "--max-line-length=112" 17 | ], 18 | "editor.formatOnSave": true 19 | } --------------------------------------------------------------------------------