From a0c1e8586760364d645608b0af058ec47fd781ce Mon Sep 17 00:00:00 2001 From: Brian Lee Date: Wed, 11 Dec 2024 15:17:45 -0800 Subject: [PATCH] New boilerplate for an openapi stack running via AWS Lambda Function --- .gitignore | 251 ++++++++++++++++++ .vscode/extensions.json | 5 + .vscode/settings.json | 8 + README.md | 127 +++++++++ containers/hello-deno/Dockerfile | 19 ++ containers/hello-deno/GUIDE.md | 43 +++ containers/hello-deno/deno.json | 0 containers/hello-deno/main.ts | 2 + containers/hello-openapi/Dockerfile | 27 ++ containers/hello-openapi/GUIDE.md | 47 ++++ containers/hello-openapi/deno.json | 5 + containers/hello-openapi/deno.lock | 78 ++++++ containers/hello-openapi/lock_dockerfile.sh | 29 ++ containers/hello-openapi/main.ts | 107 ++++++++ containers/hello-openapi/update_dockerfile.sh | 30 +++ notes/decisions.md | 14 + notes/docker.md | 52 ++++ notes/hello-hono/README.md | 7 + notes/hello-hono/deno.json | 12 + notes/hello-hono/deno.lock | 16 ++ notes/hello-hono/main.ts | 83 ++++++ notes/ide-deno-extension.png | Bin 0 -> 10988 bytes sam/samconfig.toml | 12 + sam/template.yaml | 96 +++++++ terraform/.terraform.lock.hcl | 25 ++ terraform/main.tf | 84 ++++++ terraform/outputs.tf | 9 + terraform/versions.tf | 9 + 28 files changed, 1197 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 containers/hello-deno/Dockerfile create mode 100644 containers/hello-deno/GUIDE.md create mode 100644 containers/hello-deno/deno.json create mode 100644 containers/hello-deno/main.ts create mode 100644 containers/hello-openapi/Dockerfile create mode 100644 containers/hello-openapi/GUIDE.md create mode 100644 containers/hello-openapi/deno.json create mode 100644 containers/hello-openapi/deno.lock create mode 100755 containers/hello-openapi/lock_dockerfile.sh create mode 100644 containers/hello-openapi/main.ts create mode 100644 containers/hello-openapi/update_dockerfile.sh create mode 100644 notes/decisions.md create mode 100644 notes/docker.md create mode 100644 notes/hello-hono/README.md create mode 100644 notes/hello-hono/deno.json create mode 100644 notes/hello-hono/deno.lock create mode 100644 notes/hello-hono/main.ts create mode 100644 notes/ide-deno-extension.png create mode 100644 sam/samconfig.toml create mode 100644 sam/template.yaml create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/versions.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11207d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,251 @@ + +# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Ruby plugin and RubyMine +/.rakeTasks + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Build folder + +*/build/* + +# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode +.aws-sam +*.log +archive/ +layer/opt/deno +.tmp_deno_dir +.terraform +*.tfstate* \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c4eb3fe --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "denoland.vscode-deno" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e7788bc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "deno.enable": true, + "deno.lint": true, + "editor.formatOnSave": true, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..477e9b9 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# Hello OpenAPI + +This repo contains boilerplate code for building AWS Lambda functions using Docker. See [notes/decisions.md](notes/decisions.md) for stack decisions. + +Sample containers are provided: + +* [hello-deno](containers/hello-deno/) - simplest example lambda function using Deno +* [hello-openapi](containers/hello-openapi/) - opinionated OpenAPI stack + +## IDE Note + +In VSCode, import module resolution can be handled by the `denoland.vscode-deno`. They suggest keeping it disabled globally and only enabling it per workspace. + +![enable deno extension in workspace](notes/ide-deno-extension.png) + +## Build Docker Image + +Build and test the docker image locally. + +```sh +export CONTAINER_NAME=hello-openapi +docker build -t $CONTAINER_NAME . +``` + +(Optional) Run the container locally. + +```sh +docker run -p 8000:8000 $CONTAINER_NAME:latest +curl http://localhost:8000 +docker stop $(docker ps -q) +``` + +## Deployment + +Overview: + +1. Create the ECR repository. +2. Push the container image to the ECR repository. +3. Deploy the lambda function. + +### Create the ECR repository + +You'll first need to authenticate with AWS. + +```sh +export AWS_REGION=us-west-1 +export AWS_PROFILE=playground +aws configure sso --profile $AWS_PROFILE +``` + +With Terraform: + +```sh +cd terraform +terraform init +terraform apply -target=aws_ecr_repository.hello_docker +export REPOSITORY_URI=$(terraform output -raw ecr_repository_url) +aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE | docker login --username AWS --password-stdin $REPOSITORY_URI +docker tag $CONTAINER_NAME:latest $REPOSITORY_URI:latest +docker push $REPOSITORY_URI:latest +``` + +With aws-sam-cli: + +```sh +cd sam +sam build +sam deploy --guided --profile playground --region us-west-1 --parameter-overrides DeployECROnly=true +export REPOSITORY_URI=$(aws ecr describe-repositories --repository-names $CONTAINER_NAME --region $AWS_REGION --profile $AWS_PROFILE | jq -r '.repositories[0].repositoryUri') +aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE | docker login --username AWS --password-stdin $REPOSITORY_URI +docker tag $CONTAINER_NAME:latest $REPOSITORY_URI:latest +docker push $REPOSITORY_URI:latest +``` + +In either case, we must build the ECR repository before deploying the lambda function. For sam, the `DeployECROnly` parameter is used to control this behavior for sam. You can set it to `false` in your [samconfig.toml](sam/samconfig.toml) after the first deploy. Thereafter you can use `--no-confirm-changeset` instead of `--guided`. For terraform, simply omit the `-target` parameter to build the full stack. + +### Deploy the lambda function + +With Terraform: + +```sh +terraform apply +``` + +With aws-sam-cli: + +```sh +sam deploy --guided --profile playground --region us-west-1 --parameter-overrides DeployECROnly=false --resolve-image-repos +``` + +## Tips + +Get the function url for the lambda function + +```sh +export FUNCTION_NAME= +aws lambda get-function-url-config --function-name $FUNCTION_NAME --query 'FunctionUrl' --output text +``` + +Check the image uri for a lambda function + +```sh +aws lambda get-function --function-name $FUNCTION_NAME --query 'Code.ImageUri' --output text +``` + +Review the images in an ECR repository + +```sh +export REPOSITORY_NAME=hello-world +aws ecr describe-images --repository-name $REPOSITORY_NAME --region $AWS_REGION --profile $AWS_PROFILE +``` + +## Logs + +With Terraform: + +```sh +export FUNCTION_NAME=hello_docker +aws logs tail /aws/lambda/$FUNCTION_NAME --since 1h --profile $AWS_PROFILE --region $AWS_REGION +aws logs tail /aws/lambda/$FUNCTION_NAME --follow --profile $AWS_PROFILE --region $AWS_REGION +``` + +With aws-sam-cli: + +```sh +sam logs -n HelloWorldFunction --profile playground --tail +``` diff --git a/containers/hello-deno/Dockerfile b/containers/hello-deno/Dockerfile new file mode 100644 index 0000000..b5cada2 --- /dev/null +++ b/containers/hello-deno/Dockerfile @@ -0,0 +1,19 @@ +# Set up the base image +FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 AS aws-lambda-adapter +FROM denoland/deno:bin-1.45.2 AS deno_bin +FROM debian:bookworm-20230703-slim AS deno_runtime +COPY --from=aws-lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=deno_bin /deno /usr/local/bin/deno +ENV PORT=8000 +EXPOSE 8000 +RUN mkdir /var/deno_dir +ENV DENO_DIR=/var/deno_dir + +# Copy the function code +WORKDIR "/var/task" +COPY . /var/task + +# Warmup caches +RUN timeout 10s deno run -A main.ts || [ $? -eq 124 ] || exit 1 + +CMD ["deno", "run", "-A", "main.ts"] diff --git a/containers/hello-deno/GUIDE.md b/containers/hello-deno/GUIDE.md new file mode 100644 index 0000000..4d5637b --- /dev/null +++ b/containers/hello-deno/GUIDE.md @@ -0,0 +1,43 @@ +# Guide + +## Deploy Lambda Container Image + +Create the ECR (Elastic Container Registry) repository and authenticate docker to it. + +```sh +export AWS_REGION=us-west-1 +export AWS_PROFILE=playground +aws configure sso --profile $AWS_PROFILE +aws ecr create-repository --repository-name hello-world --region $AWS_REGION --profile $AWS_PROFILE +export REPOSITORY_URI=$(aws ecr describe-repositories --repository-names hello-world --region $AWS_REGION --profile $AWS_PROFILE | jq -r '.repositories[0].repositoryUri') +aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE | docker login --username AWS --password-stdin $REPOSITORY_URI +``` + +Build the docker image and push it to the ECR repository. + +```sh +docker build -t hello-world . +docker tag hello-world:latest $REPOSITORY_URI:latest +docker push $REPOSITORY_URI:latest +``` + +### Testing + +Test the container image locally. + +```sh +docker run -p 8000:8000 hello-world:latest +``` + +Check it in another terminal tab. + +```sh +curl http://localhost:8000 +docker stop $(docker ps -q) +``` + +Cleanup the repo. + +```sh +aws ecr delete-repository --repository-name hello-world --region $AWS_REGION --profile $AWS_PROFILE --force +``` diff --git a/containers/hello-deno/deno.json b/containers/hello-deno/deno.json new file mode 100644 index 0000000..e69de29 diff --git a/containers/hello-deno/main.ts b/containers/hello-deno/main.ts new file mode 100644 index 0000000..37478ed --- /dev/null +++ b/containers/hello-deno/main.ts @@ -0,0 +1,2 @@ +// https://docs.deno.com/examples/aws_lambda_tutorial/ +Deno.serve((req) => new Response("Hello World!")); diff --git a/containers/hello-openapi/Dockerfile b/containers/hello-openapi/Dockerfile new file mode 100644 index 0000000..d3157b9 --- /dev/null +++ b/containers/hello-openapi/Dockerfile @@ -0,0 +1,27 @@ +# Set up the base image + +# https://gallery.ecr.aws/awsguru/aws-lambda-adapter +FROM public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4@sha256:e2653f741cd15851ba4f13f3cc47d29f2d14377c7d11737bfa272baa1b569007 AS aws-lambda-adapter + +# https://hub.docker.com/r/denoland/deno +# https://github.com/denoland/deno_docker +FROM docker.io/denoland/deno:bin@sha256:49206ee7e411bba0ac80047814ce9d6ceaf4eabe36f8cde7445839b91f8f5df4 AS deno_bin + +# https://hub.docker.com/_/debian +FROM docker.io/library/debian:bookworm-slim@sha256:1537a6a1cbc4b4fd401da800ee9480207e7dc1f23560c21259f681db56768f63 AS deno_runtime + +COPY --from=aws-lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter +COPY --from=deno_bin /deno /usr/local/bin/deno +ENV PORT=8000 +EXPOSE 8000 +RUN mkdir /var/deno_dir +ENV DENO_DIR=/var/deno_dir + +# Copy the function code +WORKDIR "/var/task" +COPY . /var/task + +# Warmup caches +RUN timeout 10s deno run -A main.ts || [ $? -eq 124 ] || exit 1 + +CMD ["deno", "run", "-A", "main.ts"] diff --git a/containers/hello-openapi/GUIDE.md b/containers/hello-openapi/GUIDE.md new file mode 100644 index 0000000..4821738 --- /dev/null +++ b/containers/hello-openapi/GUIDE.md @@ -0,0 +1,47 @@ +# Guide + +Build the docker image and run it locally. Or run the deno server directly: + +Test the server locally + +```sh +deno task start +``` + +Then open [http://localhost:8000/reference](http://localhost:8000/reference) in your browser. + +Test an authenticated endpoint: + +```sh +curl -H 'Authorization: Bearer hunter2' 127.0.0.1:8000/users/123 +``` + +Update the Dockerfile with the latest SHA256 digests. + +```sh +./update_dockerfile.sh hello-openapi +``` + +Build the docker image. + +```sh +docker build -t hello-openapi . +``` + +Test the container image locally. + +```sh +docker run -p 8000:8000 hello-openapi:latest +``` + +Stop it from another terminal tab. + +```sh +docker stop $(docker ps -q) +``` + +Lock the updated image digests in the Dockerfile. + +```sh +./lock_dockerfile.sh hello-openapi +``` diff --git a/containers/hello-openapi/deno.json b/containers/hello-openapi/deno.json new file mode 100644 index 0000000..8daf3c6 --- /dev/null +++ b/containers/hello-openapi/deno.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "start": "deno run --allow-net --watch main.ts" + } +} diff --git a/containers/hello-openapi/deno.lock b/containers/hello-openapi/deno.lock new file mode 100644 index 0000000..c4b84a1 --- /dev/null +++ b/containers/hello-openapi/deno.lock @@ -0,0 +1,78 @@ +{ + "version": "4", + "specifiers": { + "npm:@hono/zod-openapi@*": "0.18.3_hono@4.6.13_zod@3.23.8", + "npm:@scalar/hono-api-reference@*": "0.5.163_hono@4.6.13", + "npm:hono@*": "4.6.13" + }, + "npm": { + "@asteasolutions/zod-to-openapi@7.3.0_zod@3.23.8": { + "integrity": "sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q==", + "dependencies": [ + "openapi3-ts", + "zod" + ] + }, + "@hono/zod-openapi@0.18.3_hono@4.6.13_zod@3.23.8": { + "integrity": "sha512-bNlRDODnp7P9Fs13ZPajEOt13G0XwXKfKRHMEFCphQsFiD1Y+twzHaglpNAhNcflzR1DQwHY92ZS06b4LTPbIQ==", + "dependencies": [ + "@asteasolutions/zod-to-openapi", + "@hono/zod-validator", + "hono", + "zod" + ] + }, + "@hono/zod-validator@0.4.1_hono@4.6.13_zod@3.23.8": { + "integrity": "sha512-I8LyfeJfvVmC5hPjZ2Iij7RjexlgSBT7QJudZ4JvNPLxn0JQ3sqclz2zydlwISAnw21D2n4LQ0nfZdoiv9fQQA==", + "dependencies": [ + "hono", + "zod" + ] + }, + "@scalar/hono-api-reference@0.5.163_hono@4.6.13": { + "integrity": "sha512-kIZRMSIBT1Ac0PjWLuoIERZYTk0ZTp+B/zfK13lOjTQ8cgPnau34hsAT8Bydr9hOWENbmEPOumMBFJ1F81+PKw==", + "dependencies": [ + "@scalar/types", + "hono" + ] + }, + "@scalar/openapi-types@0.1.5": { + "integrity": "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw==" + }, + "@scalar/types@0.0.23": { + "integrity": "sha512-dOvQig4hyeVw1kXIo9MQAnM9tUt9vCOZs3zOe6oSqOUG8xY7+WXioirlRCsc+wcQegMbuNYOlNBXCDugOP1YJA==", + "dependencies": [ + "@scalar/openapi-types", + "@unhead/schema" + ] + }, + "@unhead/schema@1.11.13": { + "integrity": "sha512-fIpQx6GCpl99l4qJXsPqkXxO7suMccuLADbhaMSkeXnVEi4ZIle+l+Ri0z+GHAEpJj17FMaQdO5n9FMSOMUxkw==", + "dependencies": [ + "hookable", + "zhead" + ] + }, + "hono@4.6.13": { + "integrity": "sha512-haV0gaMdSjy9URCRN9hxBPlqHa7fMm/T72kAImIxvw4eQLbNz1rgjN4hHElLJSieDiNuiIAXC//cC6YGz2KCbg==" + }, + "hookable@5.5.3": { + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, + "openapi3-ts@4.4.0": { + "integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==", + "dependencies": [ + "yaml" + ] + }, + "yaml@2.6.1": { + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==" + }, + "zhead@2.2.4": { + "integrity": "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==" + }, + "zod@3.23.8": { + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" + } + } +} diff --git a/containers/hello-openapi/lock_dockerfile.sh b/containers/hello-openapi/lock_dockerfile.sh new file mode 100755 index 0000000..47aebb0 --- /dev/null +++ b/containers/hello-openapi/lock_dockerfile.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Function to get SHA256 digest +get_digest() { + docker pull $1 > /dev/null + digest=$(docker inspect --format='{{index .RepoDigests 0}}' $1 | cut -d'@' -f2) + echo $digest +} + +# Array of images to process +declare -A images +images["public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4"]="aws-lambda-adapter" +images["docker.io/denoland/deno:bin"]="deno_bin" +images["docker.io/library/debian:bookworm-slim"]="deno_runtime" + +# Process each image +for image in "${!images[@]}"; do + alias=${images[$image]} + digest=$(get_digest $image) + echo "Image: $image" + echo "Alias: $alias" + echo "Digest: $digest" + echo + + # Update Dockerfile + sed -i "s|FROM $image AS $alias|FROM $image@$digest AS $alias|g" Dockerfile +done + +echo "Dockerfile updated with SHA256 digests." \ No newline at end of file diff --git a/containers/hello-openapi/main.ts b/containers/hello-openapi/main.ts new file mode 100644 index 0000000..c17a23d --- /dev/null +++ b/containers/hello-openapi/main.ts @@ -0,0 +1,107 @@ +/* Define our schemas */ + +// fun fact: the @ means zod-openapi is a scoped package instead of a submodule of hono +import { z } from "npm:@hono/zod-openapi"; +import { bearerAuth } from "npm:hono/bearer-auth"; + +const ParamsSchema = z.object({ + id: z + .string() + .min(3) + .openapi({ + param: { + name: "id", + in: "path", + }, + example: "1212121", + }), +}); + +const UserSchema = z + .object({ + id: z.string().openapi({ + example: "123", + }), + name: z.string().openapi({ + example: "John Doe", + }), + age: z.number().openapi({ + example: 42, + }), + }) + .openapi("User"); + +/* Create a route */ + +import { createRoute } from "npm:@hono/zod-openapi"; + +const route = createRoute({ + method: "get", + security: [ + { + Bearer: [], + }, + ], + path: "/users/{id}", + request: { + params: ParamsSchema, + }, + responses: { + 200: { + content: { + "application/json": { + schema: UserSchema, + }, + }, + description: "Retrieve the user", + }, + }, +}); + +/* App setup */ + +import { OpenAPIHono } from "npm:@hono/zod-openapi"; + +const app = new OpenAPIHono(); + +// https://github.com/honojs/middleware/tree/main/packages/zod-openapi#how-to-setup-authorization +// https://hono.dev/docs/middleware/builtin/bearer-auth +const token = "hunter2"; +app.use("/users/*", bearerAuth({ token })); +app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { + type: "http", + scheme: "bearer", +}); + +app.openapi(route, (c) => { + const { id } = c.req.valid("param"); + return c.json({ + id, + age: 21, + name: "pleb", + }); +}); + +// OpenAPI Spec +app.doc("/openapi.json", { + openapi: "3.0.0", + info: { + version: "1.0.0", + title: "My API", + }, +}); + +/* OpenAPI Reference */ + +import { apiReference } from "npm:@scalar/hono-api-reference"; + +app.get( + "/reference", + apiReference({ + pageTitle: "Scalar/Hono API Reference", + theme: "elysiajs", + spec: { url: "/openapi.json" }, + }), +); + +Deno.serve(app.fetch); diff --git a/containers/hello-openapi/update_dockerfile.sh b/containers/hello-openapi/update_dockerfile.sh new file mode 100644 index 0000000..80daa8c --- /dev/null +++ b/containers/hello-openapi/update_dockerfile.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Function to get latest SHA256 digest +get_latest_digest() { + image_name=$(echo $1 | cut -d'@' -f1) + docker pull $image_name > /dev/null + digest=$(docker inspect --format='{{index .RepoDigests 0}}' $image_name | cut -d'@' -f2) + echo $digest +} + +# Update Dockerfile +update_dockerfile() { + old_line=$1 + new_digest=$2 + new_line=$(echo $old_line | sed "s|@sha256:[a-f0-9]*|@$new_digest|") + sed -i "s|$old_line|$new_line|" Dockerfile + echo "Updated: $new_line" +} + +# Process Dockerfile +while IFS= read -r line +do + if [[ $line == FROM* && $line == *@sha256:* ]]; then + image=$(echo $line | awk '{print $2}') + new_digest=$(get_latest_digest $image) + update_dockerfile "$line" "$new_digest" + fi +done < Dockerfile + +echo "Dockerfile updated with latest SHA256 digests." \ No newline at end of file diff --git a/notes/decisions.md b/notes/decisions.md new file mode 100644 index 0000000..e4a9e30 --- /dev/null +++ b/notes/decisions.md @@ -0,0 +1,14 @@ +# Architecture Decision Record + +A powerful stack for building APIs that is flexible, performant, and easy to reason about. + +* Cloud: AWS - status quo for QOS (we're not locked into it) +* CI/CD: Gitlab - multi-cloud, versatile, FOSS (ENA is already using it) +* Deployment: Terraform - declarative, IaC, multi-cloud +* Containers: Docker - consistency between environments, rootless in dev +* TypeScript: type-safety, anyone with JS experience can contribute (ServiceNow uses JS) +* Runtime: [Deno](https://deno.com/) - secure-by-default, URL-based module resolution, dependency locking, built-in toolchain +* API Layer: [hono](https://hono.dev/) - portable, fast, simple, automatic OpenAPI w/ [@hono/zod-openapi](https://hono.dev/examples/zod-openapi) +* OpenAPI UI: [scalar](https://scalar.com/)/[hono-api-reference](https://github.com/scalar/scalar/blob/main/packages/hono-api-reference/README.md) - offline-first, code generation + +The strong alternative would be Python with FastAPI, which might be more suitable for smaller projects. diff --git a/notes/docker.md b/notes/docker.md new file mode 100644 index 0000000..3f58195 --- /dev/null +++ b/notes/docker.md @@ -0,0 +1,52 @@ +# Build Docker Image + +Requirements: + +* sam, aws-cli, docker + +## Docker Container + +Push a docker image to the ECR repository. + +```sh +export AWS_REGION=us-west-1 +export AWS_PROFILE=playground +aws configure sso --profile $AWS_PROFILE +export REPOSITORY_URI=$(aws ecr describe-repositories --repository-names hello-world --region $AWS_REGION --profile $AWS_PROFILE | jq -r '.repositories[0].repositoryUri') +aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE | docker login --username AWS --password-stdin $REPOSITORY_URI +``` + +Build the docker image and push it to the ECR repository. + +```sh +docker build -t hello-world . +docker tag hello-world:latest $REPOSITORY_URI:latest +docker push $REPOSITORY_URI:latest +``` + +### Testing + +Test the container image locally. + +```sh +docker run -p 8000:8000 hello-world:latest +``` + +Check it in another terminal tab. + +```sh +curl http://localhost:8000 +docker stop $(docker ps -q) +``` + +Cleanup the repo. + +```sh +aws ecr delete-repository --repository-name hello-world --region $AWS_REGION --profile $AWS_PROFILE --force +``` + + +## Resources + +* [deno lambda guide](https://docs.deno.com/runtime/tutorials/aws_lambda/) +* (Deprecated) Deno on AWS Lambda: [Manual lambda packaging guide](https://github.com/denoland/deno-lambda/tree/f735a1a4f0a4a57a348f8af88d6cf30ddc0a5f2e) diff --git a/notes/hello-hono/README.md b/notes/hello-hono/README.md new file mode 100644 index 0000000..4c87c0e --- /dev/null +++ b/notes/hello-hono/README.md @@ -0,0 +1,7 @@ +``` +deno task start +``` + +``` +curl 'localhost:8000/posts/9?page=7' +``` diff --git a/notes/hello-hono/deno.json b/notes/hello-hono/deno.json new file mode 100644 index 0000000..10079d0 --- /dev/null +++ b/notes/hello-hono/deno.json @@ -0,0 +1,12 @@ +{ + "imports": { + "hono": "jsr:@hono/hono@^4.6.13" + }, + "tasks": { + "start": "deno run --allow-net --watch main.ts" + }, + "compilerOptions": { + "jsx": "precompile", + "jsxImportSource": "hono/jsx" + } +} \ No newline at end of file diff --git a/notes/hello-hono/deno.lock b/notes/hello-hono/deno.lock new file mode 100644 index 0000000..42da618 --- /dev/null +++ b/notes/hello-hono/deno.lock @@ -0,0 +1,16 @@ +{ + "version": "4", + "specifiers": { + "jsr:@hono/hono@^4.6.13": "4.6.13" + }, + "jsr": { + "@hono/hono@4.6.13": { + "integrity": "198970fc6facf71a88ee57aa1ed32143b56bcb0f842fa965ca3ee7d6fdd21a8e" + } + }, + "workspace": { + "dependencies": [ + "jsr:@hono/hono@^4.6.13" + ] + } +} diff --git a/notes/hello-hono/main.ts b/notes/hello-hono/main.ts new file mode 100644 index 0000000..d6f2acf --- /dev/null +++ b/notes/hello-hono/main.ts @@ -0,0 +1,83 @@ +import { Hono } from 'hono' + +const app = new Hono() + +// return text +app.get('/', (c) => { + return c.text('Hello Hono!') +}) + +// return JSON +app.get('/json', (c) => { + return c.json({ + ok: true, + message: 'Hello Hono!', + }) +}) + +// query params: curl 'localhost:8000/posts/9?page=7' +app.get('/posts/:id', (c) => { + const page = c.req.query('page') + const id = c.req.param('id') + c.header('X-Message', 'Hi!') + return c.text(`You want to see ${page} of ${id}`) +}) + +// create: curl -X POST localhost:8000/posts +app.post('/posts', (c) => c.text('Created!', 201)) + +// delete: curl -X DELETE localhost:8000/posts/8 +app.delete('/posts/:id', (c) => + c.text(`${c.req.param('id')} should be deleted!`) +) + +// return raw response +app.get('/raw', () => { + return new Response('Hello Hono!') +}) + +/* +// return HTML using JSX (Deno's linter hates it) +const View = () => { + return ( + + +

Hello Hono!

+ + + ) +} + +app.get('/page', (c) => { + return c.html() +}) +*/ + +// "Middleware can do the hard work for you. For example, add in Basic Authentication." +// curl -u admin:secret http://localhost:8000/admin +import { basicAuth } from 'hono/basic-auth' +app.use( + '/admin/*', + basicAuth({ + username: 'admin', + password: 'secret', + }) +) +app.get('/admin', (c) => { + return c.text('You are authorized!') +}) + +// "There are Adapters for platform-dependent functions, e.g., handling static files or WebSocket. +// For example, to handle WebSocket in Cloudflare Workers, import hono/cloudflare-workers." +/* +import { upgradeWebSocket } from 'hono/cloudflare-workers' + +app.get( + '/ws', + upgradeWebSocket((c) => { + // ... + }) +) +*/ + +Deno.serve(app.fetch) diff --git a/notes/ide-deno-extension.png b/notes/ide-deno-extension.png new file mode 100644 index 0000000000000000000000000000000000000000..f30bfde228c748f414474aa4224bce331bf34b81 GIT binary patch literal 10988 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