From bf7d9b9248d1deb73a167655fc33ccf61a3cc918 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Mon, 14 Feb 2022 09:52:11 -0600 Subject: [PATCH] Add google-native-ts-k8s-python-postgresql example --- .../.gitignore | 6 + .../.pre-commit-config.yaml | 61 ++++ .../Makefile | 37 +++ .../README.md | 156 ++++++++++ .../VERSION.txt | 1 + .../app/.dockerignore | 4 + .../app/.gitignore | 1 + .../app/Dockerfile | 10 + .../app/README.md | 0 .../app/app/__init__.py | 0 .../app/app/config.py | 14 + .../app/app/db.py | 11 + .../app/app/dependencies.py | 42 +++ .../app/app/lifespan.py | 11 + .../app/app/logconfig.py | 69 +++++ .../app/app/main.py | 30 ++ .../app/app/py.typed | 0 .../app/app/routes.py | 23 ++ .../app/requirements.txt | 278 ++++++++++++++++++ .../app/tests/__init__.py | 0 .../app/tests/test_health.py | 21 ++ .../infra/.gitignore | 4 + .../infra/Pulumi.yaml | 3 + .../infra/artifact-registry.ts | 9 + .../infra/cluster.ts | 71 +++++ .../infra/config.ts | 22 ++ .../infra/db.ts | 55 ++++ .../infra/iam.ts | 39 +++ .../infra/index.ts | 168 +++++++++++ 29 files changed, 1146 insertions(+) create mode 100644 google-native-ts-k8s-python-postgresql/.gitignore create mode 100644 google-native-ts-k8s-python-postgresql/.pre-commit-config.yaml create mode 100644 google-native-ts-k8s-python-postgresql/Makefile create mode 100644 google-native-ts-k8s-python-postgresql/README.md create mode 100644 google-native-ts-k8s-python-postgresql/VERSION.txt create mode 100644 google-native-ts-k8s-python-postgresql/app/.dockerignore create mode 100644 google-native-ts-k8s-python-postgresql/app/.gitignore create mode 100644 google-native-ts-k8s-python-postgresql/app/Dockerfile create mode 100644 google-native-ts-k8s-python-postgresql/app/README.md create mode 100644 google-native-ts-k8s-python-postgresql/app/app/__init__.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/config.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/db.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/dependencies.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/lifespan.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/logconfig.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/main.py create mode 100644 google-native-ts-k8s-python-postgresql/app/app/py.typed create mode 100644 google-native-ts-k8s-python-postgresql/app/app/routes.py create mode 100644 google-native-ts-k8s-python-postgresql/app/requirements.txt create mode 100644 google-native-ts-k8s-python-postgresql/app/tests/__init__.py create mode 100644 google-native-ts-k8s-python-postgresql/app/tests/test_health.py create mode 100644 google-native-ts-k8s-python-postgresql/infra/.gitignore create mode 100644 google-native-ts-k8s-python-postgresql/infra/Pulumi.yaml create mode 100644 google-native-ts-k8s-python-postgresql/infra/artifact-registry.ts create mode 100644 google-native-ts-k8s-python-postgresql/infra/cluster.ts create mode 100644 google-native-ts-k8s-python-postgresql/infra/config.ts create mode 100644 google-native-ts-k8s-python-postgresql/infra/db.ts create mode 100644 google-native-ts-k8s-python-postgresql/infra/iam.ts create mode 100644 google-native-ts-k8s-python-postgresql/infra/index.ts diff --git a/google-native-ts-k8s-python-postgresql/.gitignore b/google-native-ts-k8s-python-postgresql/.gitignore new file mode 100644 index 000000000..4194b142a --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/.gitignore @@ -0,0 +1,6 @@ +# Poetry's virtual env +/.venv +# hidden folders in the root directory +/.* +!/.gitignore +!/.pre-commit-config.yaml diff --git a/google-native-ts-k8s-python-postgresql/.pre-commit-config.yaml b/google-native-ts-k8s-python-postgresql/.pre-commit-config.yaml new file mode 100644 index 000000000..8f2f6ea40 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/.pre-commit-config.yaml @@ -0,0 +1,61 @@ +exclude: "^.venv/.*|.html" +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.0.1" + hooks: + - id: trailing-whitespace + exclude: ^VERSION.txt$ + - id: check-yaml + - id: pretty-format-json + args: ["--autofix"] + - id: check-merge-conflict + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "v2.5.1" + hooks: + - id: prettier + name: Run prettier on infra source code + types_or: [ts] + - repo: local + hooks: + - id: lockfile + name: Update poetry.lock + language: system + entry: poetry lock --no-update + files: ^pyproject.toml|poetry.lock$ + pass_filenames: false + - id: version + name: Update VERSION.txt -> pyproject.toml + language: system + entry: bash -c 'cat VERSION.txt | xargs poetry version' + files: ^pyproject.toml|VERSION.txt$ + pass_filenames: false + - id: requirements.txt + name: Export app dependencies to app/requirements.txt + language: system + entry: poetry export -f requirements.txt --output app/requirements.txt + files: ^pyproject.toml|poetry.lock$ + pass_filenames: false + - id: isort + name: Run isort on app source code + language: system + entry: poetry run isort + types: [python] + pass_filenames: true + - id: black + name: Run black on app source code + language: system + entry: poetry run black + types: [python] + pass_filenames: true + - id: flake8 + name: Run flake8 on app source code + language: system + entry: poetry run flake8 + types: [python] + pass_filenames: true + - id: mypy + name: Run mypy on app source code + language: system + entry: poetry run mypy + types: [python] + pass_filenames: false diff --git a/google-native-ts-k8s-python-postgresql/Makefile b/google-native-ts-k8s-python-postgresql/Makefile new file mode 100644 index 000000000..a1edca32d --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/Makefile @@ -0,0 +1,37 @@ +.PHONY: install-poetry .clean test test-mutation docs-build docs-serve + +GIT_SHA = $(shell git rev-parse --short HEAD) +PACKAGE_VERSION = $(shell poetry version -s | cut -d+ -f1) + +.install-poetry: + @echo "---- 👷 Installing build dependencies ----" + deactivate > /dev/null 2>&1 || true + pip install -U pip wheel + poetry -V || pip install -U poetry + touch .install-poetry + +install-poetry: .install-poetry + +.init: .install-poetry + @echo "---- 📦 Building package ----" + rm -rf .venv + python -m pip install -U pip wheel + poetry install + git init . + poetry run pre-commit install --install-hooks + touch .init + +.clean: + rm -rf .init .mypy_cache .pytest_cache + poetry -V || rm -rf .install-poetry + +init: .clean .init + @echo ---- 🔧 Re-initialized project ---- + +lint: .init + @echo ---- ⏳ Running linters ---- + @(poetry run pre-commit run --all-files && echo "---- ✅ Linting passed ----" && exit 0|| echo "---- ❌ Linting failed ----" && exit 1) + +test: .init + @echo ---- ⏳ Running tests ---- + @(poetry run pytest -v --cov --cov-report term && echo "---- ✅ Tests passed ----" && exit 0 || echo "---- ❌ Tests failed ----" && exit 1) diff --git a/google-native-ts-k8s-python-postgresql/README.md b/google-native-ts-k8s-python-postgresql/README.md new file mode 100644 index 000000000..423936e5e --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/README.md @@ -0,0 +1,156 @@ +# Containerized Python Xpresso app, deployed to GKE via Pulumi + +This example is a full end to end example of delivering a containerized Xpresso app. + +Using an infrastructure as code approach, running this repo will: + +- Provision a GKE cluster +- Provisions a fully managed Google Cloud SQL PostgreSQL database +- Builds a containerized Xpresso app, and it to the Google Artifact Registry +- Deploys that container image as a Kubernetes Service inside of the provisioned GKE cluster + +## Prerequisites + +Before trying to deploy this example, please make sure you have performed all of the following tasks: + +- [downloaded and installed the Pulumi CLI](https://www.pulumi.com/docs/get-started/install/). +- [downloaded and installed Docker](https://docs.docker.com/install/) +- [signed up for Google Cloud](https://cloud.google.com/free/) +- [followed the instructions here](https://www.pulumi.com/docs/intro/cloud-providers/gcp/setup/) to connect Pulumi to your Google Cloud account. + +This example assumes that you have Google Cloud's `gcloud` CLI on your path. +This is installed as part of the +[Google Cloud SDK](https://cloud.google.com/sdk/). + +As part of this example, we will setup and deploy a Kubernetes cluster on GKE. +You may also want to install [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) if you would like to directly interact with the underlying Kubernetes cluster. + +## Deploying the Example + +### Set up your GCP Project + +You'll need to create a new GCP project (or use an existing one). +Enable the following APIs in GCP if they are not already enabled: + +- [Artifact Registry](https://cloud.google.com/artifact-registry/docs/enable-service#enable) +- [Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/quickstart#before-you-begin) +- [Cloud SQL](https://cloud.google.com/sql/docs/mysql/admin-api#enable_the_api) + +If you've configured `gcloud` locally and pointed it at your project you can run: + +```shell +gcloud services enable artifactregistry.googleapis.com +gcloud services enable sqladmin.googleapis.com +gcloud services enable container.googleapis.com +``` + +### Configure Docker + +We'll be pushing a docker image to Artifact Registry, so configure docker for authentication: + +```shell +gcloud auth configure-docker +``` + +### Configure Pulumi + +Now you're ready to get started with the repo. +Clone the repo then cd into the infra directory: + +```shell +cd infra +``` + +Now set up the Pulumi stack: + +```shell +pulumi stack init dev +``` + +Set the required configuration variables for this program: + +```shell +pulumi config set xpresso-gke-demo:project [your-gcp-project-here] +pulumi config set xpresso-gke-demo:region us-west1 # any valid region +``` + +### Deploy + +Deploy everything with the `pulumi up` command. +This provisions all the GCP resources necessary, including your GKE cluster and database, as well as building and publishing your container image, all in a single gesture: + +```shell +pulumi up +``` + +This will show you a preview, ask for confirmation, and then chug away at provisioning your cluster. + +```shell +pulumi destroy +pulumi stack rm +``` + +## Local Development + +This package comes set up with some basic facilities for local development: + +- Make targets for bootstrapping, testing and linting +- Git hooks (via [pre-commit](https://pre-commit.com)) to do code formatting and syncing of derived files + +To set up locally you'll need to have Python 3.10 installed. +If you're using [pyenv](https://github.com/pyenv/pyenv), remember to select the right Python version. + +### Bootstrapping + +Run: + +```shell +make init +``` + +This will: + +- Create a virtual environment using [Poetry](https://python-poetry.org) and install all of the app's dependencies. +- Install git hooks via pre-commit. + +### Testing + +Run: + +```shell +make test +``` + +### Linting + +Linting will auto-run on each commit. +To disable this for a single commit, run: + +```shell +git commit -m "" --no-verify +``` + +To disable this permanently: + +```shell +poetry run pre-commit --uninstall +``` + +To run linting manually (without committing): + +```shell +make lint +``` + +### Versioning + +So that we can include info about the project version in our infra (in particular, we want the version in the image tag) we keep the source of truth in a `VERSION.txt` file. +This is also convenient to programmatically check for version bumps (for example in CI). + +This version is synced to the Python package version (in `pyproject.toml`) via a pre-commit hook. + +### Dependency specification + +Dependencies are specified in `pyproject.toml` and managed by Poetry. +But we don't want to have to install Poetry to build the image, so we export Poetry's lockfile to a `app/requirements.txt` via a pre-commit hook. +Then when we build the image we can just `pip install -r app/requirements.txt`. diff --git a/google-native-ts-k8s-python-postgresql/VERSION.txt b/google-native-ts-k8s-python-postgresql/VERSION.txt new file mode 100644 index 000000000..6da28dde7 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/VERSION.txt @@ -0,0 +1 @@ +0.1.1 \ No newline at end of file diff --git a/google-native-ts-k8s-python-postgresql/app/.dockerignore b/google-native-ts-k8s-python-postgresql/app/.dockerignore new file mode 100644 index 000000000..32ccab42c --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/.dockerignore @@ -0,0 +1,4 @@ +** + +!/app +!/requirements.txt diff --git a/google-native-ts-k8s-python-postgresql/app/.gitignore b/google-native-ts-k8s-python-postgresql/app/.gitignore new file mode 100644 index 000000000..bee8a64b7 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/google-native-ts-k8s-python-postgresql/app/Dockerfile b/google-native-ts-k8s-python-postgresql/app/Dockerfile new file mode 100644 index 000000000..d829e2cc4 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.10-slim +RUN mkdir /opt/project +WORKDIR /opt/project +COPY ./requirements.txt . +RUN pip install --no-cache-dir -U pip wheel && \ + pip install --no-cache-dir -r requirements.txt && \ + rm -rf requirements.txt +COPY app ./app/ +ENV PYTHONPATH "${PYTHONPATH}:/opt/project" +CMD ["python", "app/main.py"] diff --git a/google-native-ts-k8s-python-postgresql/app/README.md b/google-native-ts-k8s-python-postgresql/app/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/google-native-ts-k8s-python-postgresql/app/app/__init__.py b/google-native-ts-k8s-python-postgresql/app/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google-native-ts-k8s-python-postgresql/app/app/config.py b/google-native-ts-k8s-python-postgresql/app/app/config.py new file mode 100644 index 000000000..341b13511 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/config.py @@ -0,0 +1,14 @@ +from typing import Literal + +from pydantic import BaseSettings, SecretStr + + +class Config(BaseSettings): + app_port: int + app_host: str + db_username: str + db_password: SecretStr | None = None + db_host: str + db_port: int + db_database_name: str + log_level: Literal["DEBUG", "INFO"] diff --git a/google-native-ts-k8s-python-postgresql/app/app/db.py b/google-native-ts-k8s-python-postgresql/app/app/db.py new file mode 100644 index 000000000..70ddb2da5 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/db.py @@ -0,0 +1,11 @@ +import asyncpg # type: ignore[import] + + +class ConnectionHealth: + def __init__(self, pool: asyncpg.Pool) -> None: + self.pool = pool + + async def is_connected(self) -> bool: + conn: asyncpg.Connection + async with self.pool.acquire() as conn: + return await conn.fetchval("SELECT 1") == 1 # type: ignore diff --git a/google-native-ts-k8s-python-postgresql/app/app/dependencies.py b/google-native-ts-k8s-python-postgresql/app/app/dependencies.py new file mode 100644 index 000000000..a2b741339 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/dependencies.py @@ -0,0 +1,42 @@ +from typing import Annotated, AsyncGenerator + +import asyncpg # type: ignore[import] +from xpresso import Depends + +from app.config import Config +from app.db import ConnectionHealth as DatabaseConnectionHealth + + + +async def get_pool(config: Config) -> AsyncGenerator[asyncpg.Pool, None]: + # password is optional: + # - cloudsql proxy won't work with it + # - docker run postgres won't work without it + connection_kwargs = dict( + host=config.db_host, + port=config.db_port, + database=config.db_database_name, + user=config.db_username + ) + if config.db_password is not None: + connection_kwargs["password"] = config.db_password.get_secret_value() + async with asyncpg.create_pool(**connection_kwargs) as pool: # type: ignore + yield pool + + +InjectDBConnectionPool = Annotated[asyncpg.Pool, Depends(get_pool, scope="app")] + + +async def get_connection(pool: InjectDBConnectionPool) -> AsyncGenerator[asyncpg.Connection, None]: + async with pool.acquire() as conn: # type: ignore + yield conn + + +InjectDBConnection = Annotated[asyncpg.Connection, Depends(get_connection)] + + +def get_db_health(pool: InjectDBConnectionPool) -> DatabaseConnectionHealth: + return DatabaseConnectionHealth(pool) + + +InjectDBHealth = Annotated[DatabaseConnectionHealth, Depends(get_db_health, scope="app")] diff --git a/google-native-ts-k8s-python-postgresql/app/app/lifespan.py b/google-native-ts-k8s-python-postgresql/app/app/lifespan.py new file mode 100644 index 000000000..ddde3a8c8 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/lifespan.py @@ -0,0 +1,11 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from app.dependencies import InjectDBHealth + + +@asynccontextmanager +async def lifespan(db_health: InjectDBHealth) -> AsyncGenerator[None, None]: + if not (await db_health.is_connected()): + raise RuntimeError("Failed to connect to DB") + yield diff --git a/google-native-ts-k8s-python-postgresql/app/app/logconfig.py b/google-native-ts-k8s-python-postgresql/app/app/logconfig.py new file mode 100644 index 000000000..23edb45fc --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/logconfig.py @@ -0,0 +1,69 @@ +import logging +from typing import Any, Callable, Literal + +from pythonjsonlogger.jsonlogger import JsonFormatter # type: ignore[import] +from orjson import dumps +from uvicorn.config import LOGGING_CONFIG # type: ignore[import] + + +def _json_dumps(data: Any, default: Callable[[Any], Any] | None = None, **_) -> str: + return dumps(data, default=default).decode() + + +class _FilterUvicornColorLogs(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.__dict__.pop("color_message", None) + return True + + +def get_json_logconfig( + log_level: Literal["INFO", "DEBUG"], +) -> dict[str, Any]: + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "json": { + "()": JsonFormatter, + "timestamp": True, + "json_serializer": _json_dumps, + }, + }, + "filters": { + "filter_colorlogs": {"()": _FilterUvicornColorLogs} + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": log_level, + "formatter": "json", + "stream": "ext://sys.stdout", + "filters": ["filter_colorlogs"] + } + }, + "root": {"level": log_level, "handlers": ["console"]}, + } + + +def get_plaintext_logconfig( + log_level: Literal["INFO", "DEBUG"], +) -> dict[str, Any]: + return { + **LOGGING_CONFIG, + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "timestamped": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": log_level, + "formatter": "timestamped", + "stream": "ext://sys.stdout", + } + }, + "root": {"level": log_level, "handlers": ["console"]}, + } diff --git a/google-native-ts-k8s-python-postgresql/app/app/main.py b/google-native-ts-k8s-python-postgresql/app/app/main.py new file mode 100644 index 000000000..6a9aadfc0 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/main.py @@ -0,0 +1,30 @@ +import asyncio + +import uvicorn # type: ignore[import] +from xpresso import App + +from app.config import Config +from app.lifespan import lifespan +from app.logconfig import get_json_logconfig +from app.routes import routes + + +app = App(routes=routes, lifespan=lifespan) + + +async def main() -> None: + config = Config() # type: ignore # for Pylance + app.dependency_overrides[Config] = lambda: config + log_config = get_json_logconfig(config.log_level) + server_config = uvicorn.Config( + app, + port=config.app_port, + host=config.app_host, + log_config=log_config, + ) + server = uvicorn.Server(server_config) + await server.serve() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/google-native-ts-k8s-python-postgresql/app/app/py.typed b/google-native-ts-k8s-python-postgresql/app/app/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/google-native-ts-k8s-python-postgresql/app/app/routes.py b/google-native-ts-k8s-python-postgresql/app/app/routes.py new file mode 100644 index 000000000..7836658ee --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/app/routes.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from xpresso import Path + +from app.db import ConnectionHealth + +class DBHealth(BaseModel): + connected: bool + + +class Health(BaseModel): + db: DBHealth + + +async def health(db_health: ConnectionHealth) -> Health: + return Health(db=DBHealth(connected=await db_health.is_connected())) + + +routes = [ + Path( + "/health", + get=health, + ) +] diff --git a/google-native-ts-k8s-python-postgresql/app/requirements.txt b/google-native-ts-k8s-python-postgresql/app/requirements.txt new file mode 100644 index 000000000..ab5f37576 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/requirements.txt @@ -0,0 +1,278 @@ +anyio==3.5.0; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.2" \ + --hash=sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e \ + --hash=sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6 +asgiref==3.5.0; python_version >= "3.7" \ + --hash=sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9 \ + --hash=sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0 +asyncpg==0.25.0; python_full_version >= "3.6.0" \ + --hash=sha256:bf5e3408a14a17d480f36ebaf0401a12ff6ae5457fdf45e4e2775c51cc9517d3 \ + --hash=sha256:2bc197fc4aca2fd24f60241057998124012469d2e414aed3f992579db0c88e3a \ + --hash=sha256:1a70783f6ffa34cc7dd2de20a873181414a34fd35a4a208a1f1a7f9f695e4ec4 \ + --hash=sha256:43cde84e996a3afe75f325a68300093425c2f47d340c0fc8912765cf24a1c095 \ + --hash=sha256:56d88d7ef4341412cd9c68efba323a4519c916979ba91b95d4c08799d2ff0c09 \ + --hash=sha256:a84d30e6f850bac0876990bcd207362778e2208df0bee8be8da9f1558255e634 \ + --hash=sha256:beaecc52ad39614f6ca2e48c3ca15d56e24a2c15cbfdcb764a4320cc45f02fd5 \ + --hash=sha256:6f8f5fc975246eda83da8031a14004b9197f510c41511018e7b1bedde6968e92 \ + --hash=sha256:ddb4c3263a8d63dcde3d2c4ac1c25206bfeb31fa83bd70fd539e10f87739dee4 \ + --hash=sha256:bf6dc9b55b9113f39eaa2057337ce3f9ef7de99a053b8a16360395ce588925cd \ + --hash=sha256:acb311722352152936e58a8ee3c5b8e791b24e84cd7d777c414ff05b3530ca68 \ + --hash=sha256:0a61fb196ce4dae2f2fa26eb20a778db21bbee484d2e798cb3cc988de13bdd1b \ + --hash=sha256:2633331cbc8429030b4f20f712f8d0fbba57fa8555ee9b2f45f981b81328b256 \ + --hash=sha256:863d36eba4a7caa853fd7d83fad5fd5306f050cc2fe6e54fbe10cdb30420e5e9 \ + --hash=sha256:fe471ccd915b739ca65e2e4dbd92a11b44a5b37f2e38f70827a1c147dafe0fa8 \ + --hash=sha256:72a1e12ea0cf7c1e02794b697e3ca967b2360eaa2ce5d4bfdd8604ec2d6b774b \ + --hash=sha256:4327f691b1bdb222df27841938b3e04c14068166b3a97491bec2cb982f49f03e \ + --hash=sha256:739bbd7f89a2b2f6bc44cb8bf967dab12c5bc714fcbe96e68d512be45ecdf962 \ + --hash=sha256:18d49e2d93a7139a2fdbd113e320cc47075049997268a61bfbe0dde680c55471 \ + --hash=sha256:191fe6341385b7fdea7dbdcf47fd6db3fd198827dcc1f2b228476d13c05a03c6 \ + --hash=sha256:52fab7f1b2c29e187dd8781fce896249500cf055b63471ad66332e537e9b5f7e \ + --hash=sha256:a738f1b2876f30d710d3dc1e7858160a0afe1603ba16bf5f391f5316eb0ed855 \ + --hash=sha256:5e4105f57ad1e8fbc8b1e535d8fcefa6ce6c71081228f08680c6dea24384ff0e \ + --hash=sha256:f55918ded7b85723a5eaeb34e86e7b9280d4474be67df853ab5a7fa0cc7c6bf2 \ + --hash=sha256:649e2966d98cc48d0646d9a4e29abecd8b59d38d55c256d5c857f6b27b7407ac \ + --hash=sha256:63f8e6a69733b285497c2855464a34de657f2cccd25aeaeeb5071872e9382540 +click==8.0.3; python_version >= "3.7" \ + --hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \ + --hash=sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b +colorama==0.4.4; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" and platform_system == "Windows" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.5.0" and platform_system == "Windows" \ + --hash=sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2 \ + --hash=sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b +di==0.56.1; python_version >= "3.7" and python_version < "4" \ + --hash=sha256:eff78db82e747490e50302eed49641dd7d09ff543f3ab3ad04eb9ba65024a57d \ + --hash=sha256:d54d1f3a034cd0180b10e600f15aa29acc1b15f2d2ebf3f4f918afa6d5f2ab91 +graphlib2==0.2.16; python_version >= "3.7" and python_version < "4" \ + --hash=sha256:6b7c8d6a77c1d3f60da21b01e51e603ab973dd8986b5d644e08b85fc7e38f7df \ + --hash=sha256:c9e950c6acbea5ecd9c47ab4a3ce8c159cd4d0fddac02165a058bf9039fcb449 \ + --hash=sha256:aa6adddfda4080deb6e6037ea1c6af4a4bb20e8c3c4767ec9568e72db39af2b3 \ + --hash=sha256:c50cf2b942f69729aa1e904b8f0140bcd59b11b26aff1d23847c80597fc1f077 \ + --hash=sha256:2d8b055316c17fa08994e71841481615f0443c2bf2bf00f247599bb93f10e246 \ + --hash=sha256:b78a6d072b6309150a6a8ec63bb31c53925e185185e55dc6609c7f33cc15b871 \ + --hash=sha256:a719a079375c138f338b91e78348b4c379e47f1e181f63626daa14be5ba3afad \ + --hash=sha256:0d40015dc634f45088ec8fb7f8e7d53b7ff204a75677717991e1c6e1fea67f0a \ + --hash=sha256:26044611f933c9232dfac88fee41b5dcd34aa202bf2cdc86ed6f78c67118f76e \ + --hash=sha256:3e1d475cb759498fcb1379d8603a64259fc39753d90ea4d8c0b5ee0a42caac00 \ + --hash=sha256:57ca1875bdd17cdd4ac64bc1bccf4ea9bee8800532644fc56ce32501e03b1c7c \ + --hash=sha256:c6fabf68f06a00535a28819fec935419c4ed5b3345de4ce343ccd8be18e50f16 \ + --hash=sha256:a3709f27ced16f5c48258e35ec774907e7fed336609e56fa914ca9a66db29879 \ + --hash=sha256:e22c4986e49f3d32f49c1df0a5c4b484d9a64e88ebd9cdf7d5bf2ac7372b2809 \ + --hash=sha256:3bc5e0a6f80bbd19af99c2011f735ccc3be0d6ea5e47aa601420a6167f0e8039 \ + --hash=sha256:0dfed6a4a5feb3581a2da845c3dd5a8e57c9251117ee0fe80f446f907aa865e0 \ + --hash=sha256:b1a8911112fc8c6881155a9d91a94a1a5f36bca8300fc61b6305a7999f6909fc \ + --hash=sha256:129e2b3ff43ea4b9f34b60a5b086b4b270f1f6bd068fd6bee24278732a50d422 \ + --hash=sha256:927231758e91ebb94199e47dc6c33e202386b3ac995b9070f4a7d25a1a198161 +h11==0.13.0; python_version >= "3.7" \ + --hash=sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442 \ + --hash=sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06 +httptools==0.3.0; python_version >= "3.7" and python_full_version >= "3.5.0" \ + --hash=sha256:4137137de8976511a392e27bfdcf231bd926ac13d375e0414e927b08217d779e \ + --hash=sha256:9f475b642c48b1b78584bdd12a5143e2c512485664331eade9c29ef769a17598 \ + --hash=sha256:4687dfc116a9f1eb22a7d797f0dc6f6e17190d406ca4e729634b38aa98044b17 \ + --hash=sha256:72ee0e3fb9c6437ab3ae34e9abee67fcee6876f4f58504e3f613dd5882aafdb7 \ + --hash=sha256:3787c1f46e9722ef7f07ea5c76b0103037483d1b12e34a02c53ceca5afa4e09a \ + --hash=sha256:c0ac2e0ce6733c55858932e7d37fcc7b67ba6bb23e9648593c55f663de031b93 \ + --hash=sha256:79717080dc3f8b1eeb7f820b9b81528acbc04be6041f323fdd97550da2062575 \ + --hash=sha256:eda95634027200f4b2a6d499e7c2e7fa9b8ee57e045dfda26958ea0af27c070b \ + --hash=sha256:3f82eb106e1474c63dba36a176067e65b48385f4cecddf3616411aa5d1fbdfec \ + --hash=sha256:c14576b737d9e6e4f2a86af04918dbe9b62f57ce8102a8695c9a382dbe405c7f \ + --hash=sha256:113816f9af7dcfc4aa71ebb5354d77365f666ecf96ac7ff2aa1d24b6bca44165 \ + --hash=sha256:b8ac7dee63af4346e02b1e6d32202e3b5b3706a9928bec6da6d7a5b066217422 \ + --hash=sha256:04114db99605c9b56ea22a8ec4d7b1485b908128ed4f4a8f6438489c428da794 \ + --hash=sha256:6e676bc3bb911b11f3d7e2144b9a53600bf6b9b21e0e4437aa308e1eef094d97 \ + --hash=sha256:cdc3975db86c29817e6d13df14e037c931fc893a710fb71097777a4147090068 \ + --hash=sha256:8ac842df4fc3952efa7820b277961ea55e068bbc54cb59a0820400de7ae358d8 \ + --hash=sha256:47dba2345aaa01b87e4981e8756af441349340708d5b60712c98c55a4d28f4af \ + --hash=sha256:5a836bd85ae1fb4304f674808488dae403e136d274aa5bafd0e6ee456f11c371 \ + --hash=sha256:1a8f26327023fa1a947d36e60a0582149e182fbbc949c8a65ec8665754dbbe69 \ + --hash=sha256:32a10a5903b5bc0eb647d01cd1e95bec3bb614a9bf53f0af1e01360b2debdf81 \ + --hash=sha256:21e948034f70e47c8abfa2d5e6f1a5661f87a2cddc7bcc70f61579cc87897c70 \ + --hash=sha256:074afd8afdeec0fa6786cd4a1676e0c0be23dc9a017a86647efa6b695168104f \ + --hash=sha256:2119fa619a4c53311f594f25c0205d619350fcb32140ec5057f861952e9b2b4f \ + --hash=sha256:3f9b4856d46ba1f0c850f4e84b264a9a8b4460acb20e865ec00978ad9fbaa4cf +idna==3.3; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.2" \ + --hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \ + --hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d +orjson==3.6.6; python_version >= "3.7" \ + --hash=sha256:e4a7cad6c63306318453980d302c7c0b74c0cc290dd1f433bbd7d31a5af90cf1 \ + --hash=sha256:e533941dca4a0530a876de32e54bf2fd3269cdec3751aebde7bfb5b5eba98e74 \ + --hash=sha256:9adf63be386eaa34278967512b83ff8fc4bed036a246391ae236f68d23c47452 \ + --hash=sha256:3b636753ae34d4619b11ea7d664a2f1e87e55e9738e5123e12bcce22acae9d13 \ + --hash=sha256:78a10295ed048fd916c6584d6d27c232eae805a43e7c14be56e3745f784f0eb6 \ + --hash=sha256:82b4f9fb2af7799b52932a62eac484083f930d5519560d6f64b24d66a368d03f \ + --hash=sha256:a0033d07309cc7d8b8c4bc5d42f0dd4422b53ceb91dee9f4086bb2afa70b7772 \ + --hash=sha256:2b321f99473116ab7c7c028377372f7b4adba4029aaca19cd567e83898f55579 \ + --hash=sha256:b9c98ed94f1688cc11b5c61b8eea39d854a1a2f09f71d8a5af005461b14994ed \ + --hash=sha256:00b333a41392bd07a8603c42670547dbedf9b291485d773f90c6470eff435608 \ + --hash=sha256:8d4fd3bdee65a81f2b79c50937d4b3c054e1e6bfa3fc72ed018a97c0c7c3d521 \ + --hash=sha256:954c9f8547247cd7a8c91094ff39c9fe314b5eaeaec90b7bfb7384a4108f416f \ + --hash=sha256:74e5aed657ed0b91ef05d44d6a26d3e3e12ce4d2d71f75df41a477b05878c4a9 \ + --hash=sha256:4008a5130e6e9c33abaa95e939e0e755175da10745740aa6968461b2f16830e2 \ + --hash=sha256:012761d5f3d186deb4f6238f15e9ea7c1aac6deebc8f5b741ba3b4fafe017460 \ + --hash=sha256:b464546718a940b48d095a98df4c04808bfa6c8706fe751fc3f9390bc2f82643 \ + --hash=sha256:f10a800f4e5a4aab52076d4628e9e4dab9370bdd9d8ea254ebfde846b653ab25 \ + --hash=sha256:8010d2610cfab721725ef14d578c7071e946bbdae63322d8f7b49061cf3fde8d \ + --hash=sha256:8dca67a4855e1e0f9a2ea0386e8db892708522e1171dc0ddf456932288fbae63 \ + --hash=sha256:af065d60523139b99bd35b839c7a2d8c5da55df8a8c4402d2eb6cdc07fa7a624 \ + --hash=sha256:fa1f389cc9f766ae0cf7ba3533d5089836b01a5ccb3f8d904297f1fcf3d9dc34 \ + --hash=sha256:ec1221ad78f94d27b162a1d35672b62ef86f27f0e4c2b65051edb480cc86b286 \ + --hash=sha256:afed2af55eeda1de6b3f1cbc93431981b19d380fcc04f6ed86e74c1913070304 \ + --hash=sha256:55dd988400fa7fbe0e31407c683f5aaab013b5bd967167b8fe058186773c4d6c +pydantic==1.9.0; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.1" \ + --hash=sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5 \ + --hash=sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4 \ + --hash=sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37 \ + --hash=sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25 \ + --hash=sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6 \ + --hash=sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c \ + --hash=sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398 \ + --hash=sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65 \ + --hash=sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46 \ + --hash=sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c \ + --hash=sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054 \ + --hash=sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed \ + --hash=sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1 \ + --hash=sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070 \ + --hash=sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2 \ + --hash=sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1 \ + --hash=sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032 \ + --hash=sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6 \ + --hash=sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d \ + --hash=sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7 \ + --hash=sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77 \ + --hash=sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9 \ + --hash=sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6 \ + --hash=sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145 \ + --hash=sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034 \ + --hash=sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f \ + --hash=sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b \ + --hash=sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c \ + --hash=sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce \ + --hash=sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3 \ + --hash=sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d \ + --hash=sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721 \ + --hash=sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16 \ + --hash=sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3 \ + --hash=sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a +python-dotenv==0.19.2; python_version >= "3.7" \ + --hash=sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f \ + --hash=sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3 +python-json-logger==2.0.2; python_version >= "3.5" \ + --hash=sha256:202a4f29901a4b8002a6d1b958407eeb2dd1d83c18b18b816f5b64476dde9096 \ + --hash=sha256:99310d148f054e858cd5f4258794ed6777e7ad2c3fd7e1c1b527f1cba4d08420 +pyyaml==6.0; python_version >= "3.7" \ + --hash=sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53 \ + --hash=sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c \ + --hash=sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc \ + --hash=sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b \ + --hash=sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5 \ + --hash=sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513 \ + --hash=sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a \ + --hash=sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86 \ + --hash=sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f \ + --hash=sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92 \ + --hash=sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4 \ + --hash=sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293 \ + --hash=sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57 \ + --hash=sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c \ + --hash=sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0 \ + --hash=sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4 \ + --hash=sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9 \ + --hash=sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737 \ + --hash=sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d \ + --hash=sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b \ + --hash=sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba \ + --hash=sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34 \ + --hash=sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287 \ + --hash=sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78 \ + --hash=sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07 \ + --hash=sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b \ + --hash=sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174 \ + --hash=sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803 \ + --hash=sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3 \ + --hash=sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0 \ + --hash=sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb \ + --hash=sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c \ + --hash=sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2 +sniffio==1.2.0; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.2" \ + --hash=sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663 \ + --hash=sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de +starlette==0.18.0; python_version >= "3.7" and python_version < "4" \ + --hash=sha256:377d64737a0e03560cb8eaa57604afee143cea5a4996933242798a7820e64f53 \ + --hash=sha256:b45c6e9a617ecb5caf7e6446bd8d767b0084d6217e8e1b08187ca5191e10f097 +typing-extensions==4.1.1; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.1" \ + --hash=sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2 \ + --hash=sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42 +uvicorn==0.17.4; python_version >= "3.7" \ + --hash=sha256:e85872d84fb651cccc4c5d2a71cf7ead055b8fb4d8f1e78e36092282c0cf2aec \ + --hash=sha256:25850bbc86195a71a6477b3e4b3b7b4c861fb687fb96912972ce5324472b1011 +uvloop==0.16.0; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.7" \ + --hash=sha256:6224f1401025b748ffecb7a6e2652b17768f30b1a6a3f7b44660e5b5b690b12d \ + --hash=sha256:30ba9dcbd0965f5c812b7c2112a1ddf60cf904c1c160f398e7eed3a6b82dcd9c \ + --hash=sha256:bd53f7f5db562f37cd64a3af5012df8cac2c464c97e732ed556800129505bd64 \ + --hash=sha256:772206116b9b57cd625c8a88f2413df2fcfd0b496eb188b82a43bed7af2c2ec9 \ + --hash=sha256:b572256409f194521a9895aef274cea88731d14732343da3ecdb175228881638 \ + --hash=sha256:04ff57aa137230d8cc968f03481176041ae789308b4d5079118331ab01112450 \ + --hash=sha256:3a19828c4f15687675ea912cc28bbcb48e9bb907c801873bd1519b96b04fb805 \ + --hash=sha256:e814ac2c6f9daf4c36eb8e85266859f42174a4ff0d71b99405ed559257750382 \ + --hash=sha256:bd8f42ea1ea8f4e84d265769089964ddda95eb2bb38b5cbe26712b0616c3edee \ + --hash=sha256:647e481940379eebd314c00440314c81ea547aa636056f554d491e40503c8464 \ + --hash=sha256:8e0d26fa5875d43ddbb0d9d79a447d2ace4180d9e3239788208527c4784f7cab \ + --hash=sha256:6ccd57ae8db17d677e9e06192e9c9ec4bd2066b77790f9aa7dede2cc4008ee8f \ + --hash=sha256:089b4834fd299d82d83a25e3335372f12117a7d38525217c2258e9b9f4578897 \ + --hash=sha256:98d117332cc9e5ea8dfdc2b28b0a23f60370d02e1395f88f40d1effd2cb86c4f \ + --hash=sha256:1e5f2e2ff51aefe6c19ee98af12b4ae61f5be456cd24396953244a30880ad861 \ + --hash=sha256:f74bc20c7b67d1c27c72601c78cf95be99d5c2cdd4514502b4f3eb0933ff1228 +watchgod==0.7; python_version >= "3.7" \ + --hash=sha256:d6c1ea21df37847ac0537ca0d6c2f4cdf513562e95f77bb93abbcf05573407b7 \ + --hash=sha256:48140d62b0ebe9dd9cf8381337f06351e1f2e70b2203fa9c6eff4e572ca84f29 +websockets==10.1; python_version >= "3.7" \ + --hash=sha256:38db6e2163b021642d0a43200ee2dec8f4980bdbda96db54fde72b283b54cbfc \ + --hash=sha256:e1b60fd297adb9fc78375778a5220da7f07bf54d2a33ac781319650413fc6a60 \ + --hash=sha256:3477146d1f87ead8df0f27e8960249f5248dceb7c2741e8bbec9aa5338d0c053 \ + --hash=sha256:bb01ea7b5f52e7125bdc3c5807aeaa2d08a0553979cf2d96a8b7803ea33e15e7 \ + --hash=sha256:9fd62c6dc83d5d35fb6a84ff82ec69df8f4657fff05f9cd6c7d9bec0dd57f0f6 \ + --hash=sha256:3bbf080f3892ba1dc8838786ec02899516a9d227abe14a80ef6fd17d4fb57127 \ + --hash=sha256:5560558b0dace8312c46aa8915da977db02738ac8ecffbc61acfbfe103e10155 \ + --hash=sha256:667c41351a6d8a34b53857ceb8343a45c85d438ee4fd835c279591db8aeb85be \ + --hash=sha256:468f0031fdbf4d643f89403a66383247eb82803430b14fa27ce2d44d2662ca37 \ + --hash=sha256:d0d81b46a5c87d443e40ce2272436da8e6092aa91f5fbeb60d1be9f11eff5b4c \ + --hash=sha256:b68b6caecb9a0c6db537aa79750d1b592a841e4f1a380c6196091e65b2ad35f9 \ + --hash=sha256:a249139abc62ef333e9e85064c27fefb113b16ffc5686cefc315bdaef3eefbc8 \ + --hash=sha256:8877861e3dee38c8d302eee0d5dbefa6663de3b46dc6a888f70cd7e82562d1f7 \ + --hash=sha256:e3872ae57acd4306ecf937d36177854e218e999af410a05c17168cd99676c512 \ + --hash=sha256:b66e6d514f12c28d7a2d80bb2a48ef223342e99c449782d9831b0d29a9e88a17 \ + --hash=sha256:9f304a22ece735a3da8a51309bc2c010e23961a8f675fae46fdf62541ed62123 \ + --hash=sha256:189ed478395967d6a98bb293abf04e8815349e17456a0a15511f1088b6cb26e4 \ + --hash=sha256:08a42856158307e231b199671c4fce52df5786dd3d703f36b5d8ac76b206c485 \ + --hash=sha256:3ef6f73854cded34e78390dbdf40dfdcf0b89b55c0e282468ef92646fce8d13a \ + --hash=sha256:89e985d40d407545d5f5e2e58e1fdf19a22bd2d8cd54d20a882e29f97e930a0a \ + --hash=sha256:002071169d2e44ce8eb9e5ebac9fbce142ba4b5146eef1cfb16b177a27662657 \ + --hash=sha256:cfae282c2aa7f0c4be45df65c248481f3509f8c40ca8b15ed96c35668ae0ff69 \ + --hash=sha256:97b4b68a2ddaf5c4707ae79c110bfd874c5be3c6ac49261160fb243fa45d8bbb \ + --hash=sha256:7c9407719f42cb77049975410490c58a705da6af541adb64716573e550e5c9db \ + --hash=sha256:1d858fb31e5ac992a2cdf17e874c95f8a5b1e917e1fb6b45ad85da30734b223f \ + --hash=sha256:7bdd3d26315db0a9cf8a0af30ca95e0aa342eda9c1377b722e71ccd86bc5d1dd \ + --hash=sha256:e259be0863770cb91b1a6ccf6907f1ac2f07eff0b7f01c249ed751865a70cb0d \ + --hash=sha256:6b014875fae19577a392372075e937ebfebf53fd57f613df07b35ab210f31534 \ + --hash=sha256:98de71f86bdb29430fd7ba9997f47a6b10866800e3ea577598a786a785701bb0 \ + --hash=sha256:3a02ab91d84d9056a9ee833c254895421a6333d7ae7fff94b5c68e4fa8095519 \ + --hash=sha256:7d6673b2753f9c5377868a53445d0c321ef41ff3c8e3b6d57868e72054bfce5f \ + --hash=sha256:ddab2dc69ee5ae27c74dbfe9d7bb6fee260826c136dca257faa1a41d1db61a89 \ + --hash=sha256:14e9cf68a08d1a5d42109549201aefba473b1d925d233ae19035c876dd845da9 \ + --hash=sha256:e4819c6fb4f336fd5388372cb556b1f3a165f3f68e66913d1a2fc1de55dc6f58 \ + --hash=sha256:05e7f098c76b0a4743716590bb8f9706de19f1ef5148d61d0cf76495ec3edb9c \ + --hash=sha256:5bb6256de5a4fb1d42b3747b4e2268706c92965d75d0425be97186615bf2f24f \ + --hash=sha256:888a5fa2a677e0c2b944f9826c756475980f1b276b6302e606f5c4ff5635be9e \ + --hash=sha256:6fdec1a0b3e5630c58e3d8704d2011c678929fce90b40908c97dfc47de8dca72 \ + --hash=sha256:531d8eb013a9bc6b3ad101588182aa9b6dd994b190c56df07f0d84a02b85d530 \ + --hash=sha256:0d93b7cadc761347d98da12ec1930b5c71b2096f1ceed213973e3cda23fead9c \ + --hash=sha256:d9b245db5a7e64c95816e27d72830e51411c4609c05673d1ae81eb5d23b0be54 \ + --hash=sha256:882c0b8bdff3bf1bd7f024ce17c6b8006042ec4cceba95cf15df57e57efa471c \ + --hash=sha256:10edd9d7d3581cfb9ff544ac09fc98cab7ee8f26778a5a8b2d5fd4b0684c5ba5 \ + --hash=sha256:baa83174390c0ff4fc1304fbe24393843ac7a08fdd59295759c4b439e06b1536 \ + --hash=sha256:483edee5abed738a0b6a908025be47f33634c2ad8e737edd03ffa895bd600909 \ + --hash=sha256:816ae7dac2c6522cfa620947ead0ca95ac654916eebf515c94d7c28de5601a6e \ + --hash=sha256:1dafe98698ece09b8ccba81b910643ff37198e43521d977be76caf37709cf62b \ + --hash=sha256:181d2b25de5a437b36aefedaf006ecb6fa3aa1328ec0236cdde15f32f9d3ff6d +xpresso==0.17.1; python_version >= "3.7" and python_version < "4" \ + --hash=sha256:543823f192b2b9ed9dea6c92e2c1df06e665851df66cfc8572be073345894944 \ + --hash=sha256:21b1318a8225cc4b90497986a23bf1d8ad65e668c54a7f50abe31cbd9f21c6ed diff --git a/google-native-ts-k8s-python-postgresql/app/tests/__init__.py b/google-native-ts-k8s-python-postgresql/app/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google-native-ts-k8s-python-postgresql/app/tests/test_health.py b/google-native-ts-k8s-python-postgresql/app/tests/test_health.py new file mode 100644 index 000000000..476d590bc --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/app/tests/test_health.py @@ -0,0 +1,21 @@ +from xpresso.testclient import TestClient + +from app.main import app +from app.db import ConnectionHealth + + +def test_health() -> None: + class FakeHealth(ConnectionHealth): + def __init__(self) -> None: + pass + async def is_connected(self) -> bool: + return True + + with app.dependency_overrides as overrides: + overrides[ConnectionHealth] = FakeHealth + + client = TestClient(app) + resp = client.get("/health") + + assert resp.status_code == 200, resp.content + assert resp.json() == {"db": {"connected": True}} diff --git a/google-native-ts-k8s-python-postgresql/infra/.gitignore b/google-native-ts-k8s-python-postgresql/infra/.gitignore new file mode 100644 index 000000000..afee70b2f --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +kubeconfig.yml +Pulumi.*.yaml +!Pulumi.yaml diff --git a/google-native-ts-k8s-python-postgresql/infra/Pulumi.yaml b/google-native-ts-k8s-python-postgresql/infra/Pulumi.yaml new file mode 100644 index 000000000..68e0d8ad0 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/Pulumi.yaml @@ -0,0 +1,3 @@ +name: xpresso-gke-demo +runtime: nodejs +description: A demo Xpresso app running on GCP with IaC using Pulumi diff --git a/google-native-ts-k8s-python-postgresql/infra/artifact-registry.ts b/google-native-ts-k8s-python-postgresql/infra/artifact-registry.ts new file mode 100644 index 000000000..f4d32f212 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/artifact-registry.ts @@ -0,0 +1,9 @@ +import * as gcloud from "@pulumi/google-native"; +import * as config from "./config"; + +export const dockerRegistry = new gcloud.artifactregistry.v1.Repository("registry", { + project: config.project, + location: "us", + format: gcloud.artifactregistry.v1.RepositoryFormat.Docker, + repositoryId: config.artifactRegistryDockerRepositoryId, +}); diff --git a/google-native-ts-k8s-python-postgresql/infra/cluster.ts b/google-native-ts-k8s-python-postgresql/infra/cluster.ts new file mode 100644 index 000000000..da72b898e --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/cluster.ts @@ -0,0 +1,71 @@ +import * as gcloud from "@pulumi/google-native"; +import * as k8s from "@pulumi/kubernetes"; +import * as pulumi from "@pulumi/pulumi"; +import * as config from "./config"; +import { db } from "./db"; + +const cluster = new gcloud.container.v1.Cluster("cluster", { + location: config.region, + autopilot: { enabled: true}, + project: config.project, + releaseChannel: { "channel": gcloud.container.v1.ReleaseChannelChannel.Regular }, + nodePools: [ + { + name: "initial", + config: { + oauthScopes: [ + "https://www.googleapis.com/auth/devstorage.read_only", + "https://www.googleapis.com/auth/logging.write", + "https://www.googleapis.com/auth/monitoring", + "https://www.googleapis.com/auth/service.management.readonly", + "https://www.googleapis.com/auth/servicecontrol", + "https://www.googleapis.com/auth/trace.append", + "https://www.googleapis.com/auth/compute", + ], + }, + } + ], + workloadIdentityConfig: { + workloadPool: `${config.project}.svc.id.goog`, +}, +}, { dependsOn: [db] }); + +// Manufacture a GKE-style Kubeconfig. Note that this is slightly "different" because of the way GKE requires +// gcloud to be in the picture for cluster authentication (rather than using the client cert/key directly). +export const kubeConfig = pulumi. + all([cluster.name, cluster.endpoint, cluster.location, cluster.masterAuth]). + apply(([name, endpoint, location, auth]) => { + const context = `${config.project}_${location}_${name}`; + return `apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: ${auth.clusterCaCertificate} + server: https://${endpoint} + name: ${context} +contexts: +- context: + cluster: ${context} + user: ${context} + name: ${context} +current-context: ${context} +kind: Config +preferences: {} +users: +- name: ${context} + user: + auth-provider: + config: + cmd-args: config config-helper --format=json + cmd-path: gcloud + expiry-key: '{.credential.token_expiry}' + token-key: '{.credential.access_token}' + name: gcp +`; + }); + +// Export a Kubernetes provider instance that uses our cluster from above. +export const provider = new k8s.Provider("gke-k8s", { + kubeconfig: kubeConfig, +}, { + dependsOn: [cluster], +}); diff --git a/google-native-ts-k8s-python-postgresql/infra/config.ts b/google-native-ts-k8s-python-postgresql/infra/config.ts new file mode 100644 index 000000000..ca38d11d1 --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/config.ts @@ -0,0 +1,22 @@ +// Copyright 2016-2021, Pulumi Corporation. All rights reserved. + +import { Config } from "@pulumi/pulumi"; + +const config = new Config("xpresso-gke-demo"); + +export const project = config.require("project"); +export const region = config.require("region"); + +/// Artifact Registry config + +export const artifactRegistryDockerRepositoryId = `${project}-registry`; + +/// PostgreSQL config +export const dbName = "app"; + +/// App config +export const appPort = 8000; + +/// Kubernetes config +export const appServiceAccountName = "app-sa"; +export const namespace = "default"; diff --git a/google-native-ts-k8s-python-postgresql/infra/db.ts b/google-native-ts-k8s-python-postgresql/infra/db.ts new file mode 100644 index 000000000..10820e24c --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/db.ts @@ -0,0 +1,55 @@ +import * as classic from "@pulumi/gcp"; +import * as pulumi from "@pulumi/pulumi"; +import * as random from "@pulumi/random"; +import * as gcloud from "@pulumi/google-native"; +import { dbName, project } from "./config"; +import * as iam from "./iam" + +// Provision a database for our app. +export const instance = new gcloud.sqladmin.v1.Instance("web-db", { + databaseVersion: "POSTGRES_14", + project: project, + settings: { + settingsVersion: "1", + tier: "db-f1-micro", + databaseFlags: [ + { name: "cloudsql.iam_authentication", value: "on"} + ], + availabilityType: "REGIONAL", + backupConfiguration: { + enabled: true, + }, + }, +}); + +export const db = new gcloud.sqladmin.v1.Database("db", { + name: dbName, + instance: instance.name, + project: project, +}); + +// Create a user with the configured credentials for the app to use. +// TODO: Switch to google native version when User is supported: +// https://github.com/pulumi/pulumi-google-native/issues/47 +export const user = new classic.sql.User("web-db-user", { + instance: instance.name, + // The name MUST be the service account email + // WITHOUT the .gserviceaccount.com suffix + // See https://cloud.google.com/sql/docs/postgres/iam-logins + name: iam.serviceAccount.email.apply(v => v.replace(".gserviceaccount.com", "")), + password: genRandomPassword("dbPassword", 16), + project: project, + // Careful here: CLOUD_IAM_SERVICE_ACCOUNT != CLOUD_IAM_USER + // The latter is for end-user IAM access, not service accounts + type: "CLOUD_IAM_SERVICE_ACCOUNT", +}); + +function genRandomPassword(name: string, length: number): pulumi.Output { + let password = new random.RandomString(name, { + upper: false, + number: true, + special: true, + length: length, + }).result; + return pulumi.secret(password); +}; diff --git a/google-native-ts-k8s-python-postgresql/infra/iam.ts b/google-native-ts-k8s-python-postgresql/infra/iam.ts new file mode 100644 index 000000000..210b2ceef --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/iam.ts @@ -0,0 +1,39 @@ + +import * as google from "@pulumi/google-native"; +import * as pulumi from "@pulumi/pulumi"; +import * as random from "@pulumi/random"; +import * as config from "./config"; + + +// Create a random suffix for the ServiceAccount. +const saSuffix = new random.RandomPet("saSuffix", {length: 2}).id; + +// Create a ServiceAccount +export const serviceAccount = new google.iam.v1.ServiceAccount("appServiceAccount", { + accountId: pulumi.interpolate`app-${saSuffix}`, + project: config.project, +}); + +const existingPolicy = google.cloudresourcemanager.v1.getProjectIamPolicy({resource: config.project}); +existingPolicy.then(p => { + const bindings = [ + { + members: pulumi.all([pulumi.interpolate `serviceAccount:${serviceAccount.email}`]), + role: "roles/cloudsql.instanceUser", + }, + { + members: pulumi.all([pulumi.interpolate `serviceAccount:${serviceAccount.email}`]), + role: "roles/cloudsql.client", + }, + { + members: pulumi.all([pulumi.interpolate`serviceAccount:${config.project}.svc.id.goog[default/${config.appServiceAccountName}]`]), + role: "roles/iam.workloadIdentityUser", + } + ]; + p.bindings.map(b => bindings.push({members: pulumi.all(b.members), role: b.role})); + // Update the bindings to include the service account + new google.cloudresourcemanager.v1.ProjectIamPolicy("app-sa-policy", { + bindings: bindings, + resource: config.project, + }); +}); diff --git a/google-native-ts-k8s-python-postgresql/infra/index.ts b/google-native-ts-k8s-python-postgresql/infra/index.ts new file mode 100644 index 000000000..3b9cec45c --- /dev/null +++ b/google-native-ts-k8s-python-postgresql/infra/index.ts @@ -0,0 +1,168 @@ +// Copyright 2016-2021, Pulumi Corporation. All rights reserved. + +import { readFileSync } from "fs"; +import * as docker from "@pulumi/docker"; +import * as k8s from "@pulumi/kubernetes"; +import * as pulumi from "@pulumi/pulumi"; +import * as cluster from "./cluster"; +import * as config from "./config"; +import * as db from "./db"; +import * as iam from "./iam"; +import { dockerRegistry } from "./artifact-registry"; +import { execSync } from "child_process"; + +const gitHash = execSync("git rev-parse --short HEAD").toString().trim(); + +const projectVersion = readFileSync("../VERSION.txt", "utf8").trim(); + +// We build and push the image to the GCP project's Artifact Registry. +// Make sure docker is configured to use docker registry by running +// > gcloud auth configure-docker +// before running pulumi up +const appImageName = pulumi.interpolate`us-docker.pkg.dev/${config.project}/${config.artifactRegistryDockerRepositoryId}/app:${projectVersion}-${gitHash}`; +const appImage = new docker.Image( + "app", + { + imageName: appImageName, + build: { + context: "../app", + }, + }, + { dependsOn: [dockerRegistry] } +); + +// Create a k8s service account that binds our GCP service account +const kubernetesServiceAccount = new k8s.core.v1.ServiceAccount( + "app", + { + metadata: { + name: config.appServiceAccountName, + annotations: { + "iam.gke.io/gcp-service-account": iam.serviceAccount.email, + }, + }, + }, + { provider: cluster.provider } +); +// Define the containers +const appContainer = { + name: "app", + image: appImage.imageName, + imagePullPolicy: "IfNotPresent", + env: [ + { name: "LOG_LEVEL", value: "INFO" }, + { name: "APP_PORT", value: config.appPort.toString() }, + { name: "APP_HOST", value: "0.0.0.0" }, + { name: "DB_HOST", value: "localhost" }, + { name: "DB_PORT", value: "5432" }, + { name: "DB_USERNAME", value: db.user.name }, + { name: "DB_DATABASE_NAME", value: db.db.name }, + ], + ports: [{ containerPort: config.appPort }], + livenessProbe: { + initialDelaySeconds: 15, + periodSeconds: 10, + httpGet: { + path: "/health", + port: config.appPort, + }, + }, + resources: { + requests: { + cpu: "250m", + memory: "512Mi", + }, + }, +}; +const dbInstance = pulumi.interpolate`${config.project}:${config.region}:${db.instance.name}`; +export const SQLProxyContainer = { + name: "cloudsql-proxy", + image: "gcr.io/cloudsql-docker/gce-proxy", + command: [ + "/cloud_sql_proxy", + pulumi.interpolate`-instances=${dbInstance}=tcp:5432`, + "-enable_iam_login", + ], + imagePullPolicy: "IfNotPresent", + resources: { + requests: { + cpu: "250m", + memory: "512Mi", + }, + }, +}; +// Deploy the app container as a Kubernetes load balanced service. +const appLabels = { app: "app" }; +const appDeployment = new k8s.apps.v1.Deployment( + "app-deployment", + { + spec: { + selector: { matchLabels: appLabels }, + replicas: 1, + template: { + metadata: { labels: appLabels }, + spec: { + containers: [appContainer, SQLProxyContainer], + serviceAccount: kubernetesServiceAccount.metadata.name, + }, + }, + }, + }, + { provider: cluster.provider, dependsOn: [appImage] } +); +const appService = new k8s.core.v1.Service( + "app-service", + { + metadata: { labels: appDeployment.metadata.labels }, + spec: { + type: "LoadBalancer", + ports: [{ port: 80, targetPort: config.appPort }], + selector: appDeployment.spec.template.metadata.labels, + }, + }, + { provider: cluster.provider } +); +export const HPA = new k8s.autoscaling.v2beta2.HorizontalPodAutoscaler( + "hpa", + { + metadata: { + name: "hpa", + }, + spec: { + scaleTargetRef: { + apiVersion: "apps/v1", + kind: "Deployment", + name: appDeployment.metadata.name, + }, + // Choose at least 2 replicas for resiliency + minReplicas: 2, + // But a low max since this is a demo and we want to keep costs low + maxReplicas: 3, + metrics: [ + { + type: "Resource", + resource: { + name: "cpu", + target: { + type: "Utilization", + // Arbitrary choice + averageUtilization: 35, + }, + }, + }, + ], + }, + }, + { parent: cluster.provider } +); + +// Export the app deployment name so we can easily access it. +export let appName = appDeployment.metadata.name; + +// Export the service's address. +export let appAddress = appService.status.apply( + (s) => `http://${s.loadBalancer.ingress[0].ip}:${config.appPort}` +); + +// Also export the Kubeconfig so that clients can easily access our cluster. +export let kubeConfig = cluster.kubeConfig;