commit a0c1e8586760364d645608b0af058ec47fd781ce Author: Brian Lee Date: Wed Dec 11 15:17:45 2024 -0800 New boilerplate for an openapi stack running via AWS Lambda Function 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 0000000..f30bfde Binary files /dev/null and b/notes/ide-deno-extension.png differ diff --git a/sam/samconfig.toml b/sam/samconfig.toml new file mode 100644 index 0000000..04d028f --- /dev/null +++ b/sam/samconfig.toml @@ -0,0 +1,12 @@ +version = 0.1 +[default.deploy.parameters] +stack_name = "hello-world" +resolve_s3 = true +s3_prefix = "hello-world" +region = "us-west-1" +profile = "playground" +confirm_changeset = true +capabilities = "CAPABILITY_IAM" +parameter_overrides = "DeployECROnly=\"false\"" +image_repositories = [] +resolve_image_repos = true diff --git a/sam/template.yaml b/sam/template.yaml new file mode 100644 index 0000000..cbdd715 --- /dev/null +++ b/sam/template.yaml @@ -0,0 +1,96 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Docker Container Lambda Function + +Globals: + Function: + Timeout: 10 + +Parameters: + DeployECROnly: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: If true, only deploys the ECR repository + +Conditions: + DeployFullStack: !Equals [!Ref DeployECROnly, "false"] + +Resources: + + HelloWorldRepository: + Type: AWS::ECR::Repository + Properties: + RepositoryName: hello-world + ImageScanningConfiguration: + ScanOnPush: true + + HelloWorldFunctionRole: + Condition: DeployFullStack + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Principal: + Service: + - "lambda.amazonaws.com" + Action: + - "sts:AssumeRole" + Policies: + - PolicyName: HelloWorldFunctionAccess + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: + - "secretsmanager:GetSecretValue" + Resource: + - "arn:aws:secretsmanager:us-west-1:003525187774:secret:*" + ManagedPolicyArns: + # - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess + - arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs + + HelloWorldFunction: + Condition: DeployFullStack + Type: AWS::Serverless::Function + Properties: + PackageType: Image + ImageUri: !Sub "${HelloWorldRepository.RepositoryUri}:latest" +# ImageUri: !Sub "${HelloWorldRepository.RepositoryUri}@sha256:c195f0c7d0bb2a5bdf408f149f4cf558e3376128a4887b5cd0a6fb2196992bf3" + Role: !GetAtt HelloWorldFunctionRole.Arn + Environment: + Variables: + PORT: 8000 + Architectures: + - x86_64 + FunctionUrlConfig: + AuthType: NONE + Cors: + AllowOrigins: + - '*' + + HelloWorldFunctionPermission: + Condition: DeployFullStack + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref HelloWorldFunction + Action: lambda:InvokeFunctionUrl + Principal: '*' + FunctionUrlAuthType: NONE + +Outputs: + HelloWorldRepositoryUri: + Description: ECR Repository URI + Value: !GetAtt HelloWorldRepository.RepositoryUri + HelloWorldFunctionName: + Condition: DeployFullStack + Description: Lambda Function Name + Value: !Ref HelloWorldFunction + +# Error: Requested attribute FunctionUrl does not exist in schema for AWS::Lambda::Function +# HelloWorldFunctionUrl: +# Condition: DeployFullStack +# Description: "The URL of the Lambda Function" +# Value: !GetAtt HelloWorldFunction.FunctionUrl \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..a5e800b --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.80.0" + constraints = "~> 5.0" + hashes = [ + "h1:N5Wfsf4xe5DJfSeo0G/ulkIxzyfmUIoSj/hAiZ2DaKU=", + "zh:0b1655e39639d60f2de2860a5df8642f9556ba0ca04529c1b861fde4935cb0df", + "zh:13dc0155e0a11edceee29ce687fc04c5a5a85f3324c67556472713cfd52e5807", + "zh:180f6cb2be44be14cfe329e0649121b774319f083b6e4e8fb749f85090d73121", + "zh:3158d44b74c67465f7f19f22c42b643840c8d18ce833e2ec86e8d93085b06926", + "zh:6351b5bf7cde5dc83e926944891570636069e05ca43341f4d1feda67773469bf", + "zh:6fa9db1532096ba50e842d369b6688979306d2295c7ead49b8a266b0d60962cc", + "zh:85d2fe75def7619ff2cc29102048875039cad088fafb62ecc14c3763e7b1e9d9", + "zh:9028d653f1d7341c6dfe2afe961b6541581e9043a474eac2faf90e6426a24f6d", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c4e248c442bc60f07f9f089e5361f19936833370dc3c04b27916672b765f0e1", + "zh:a710a3979596e3f3938c3ec6bb748e604724d3a4afa96ed2c14f0a245cc41a11", + "zh:c27936bdf447779d0c0833bf52a9ef618985f5ea8e3e243d6266513520ca31c4", + "zh:c7681134a123486e72eaedc3f8d2d75e267dbbfd45fa7de5aea8f757af57f89b", + "zh:ea717ebad3561fd02591f9eecf30f3df5635405556fba2bdbf29fd42691bebac", + "zh:f4e1e8f23c58c3e8f4371f9c3379a723ab4155246e6b6daad8eb99e16666b2cb", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..90d96f0 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,84 @@ +provider "aws" { + region = "us-west-1" + profile = "playground" +} + +# ECR Repository +resource "aws_ecr_repository" "hello_docker" { + name = "hello-docker" + image_scanning_configuration { + scan_on_push = true + } +} + +# IAM Role for Lambda +resource "aws_iam_role" "hello_docker_role" { + name = "hello_docker_lambda_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +# IAM Policy for Secrets Manager access +resource "aws_iam_role_policy" "hello_docker_policy" { + name = "hello_docker_function_access" + role = aws_iam_role.hello_docker_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = ["secretsmanager:GetSecretValue"] + Resource = ["arn:aws:secretsmanager:us-west-1:003525187774:secret:*"] + }] + }) +} + +# Attach CloudWatch Logs policy +resource "aws_iam_role_policy_attachment" "hello_docker_logs" { + role = aws_iam_role.hello_docker_role.name + policy_arn = "arn:aws:iam::aws:policy/AWSOpsWorksCloudWatchLogs" +} + +# Lambda Function +resource "aws_lambda_function" "hello_docker" { + function_name = "hello-docker" + role = aws_iam_role.hello_docker_role.arn + package_type = "Image" + image_uri = "${aws_ecr_repository.hello_docker.repository_url}:latest" + architectures = ["x86_64"] + timeout = 10 + + environment { + variables = { + PORT = "8000" + } + } +} + +# Lambda Function URL +resource "aws_lambda_function_url" "hello_docker_url" { + function_name = aws_lambda_function.hello_docker.function_name + authorization_type = "NONE" + + cors { + allow_origins = ["*"] + } +} + +# Lambda permission for Function URL +resource "aws_lambda_permission" "function_url" { + statement_id = "AllowExecutionFromFunctionURL" + action = "lambda:InvokeFunctionUrl" + function_name = aws_lambda_function.hello_docker.function_name + principal = "*" + function_url_auth_type = "NONE" +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..61d6aa0 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "function_url" { + description = "Lambda Function URL" + value = aws_lambda_function_url.hello_docker_url.function_url +} + +output "ecr_repository_url" { + description = "ECR Repository URI" + value = aws_ecr_repository.hello_docker.repository_url +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..a859243 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + required_version = ">= 1.0" +} \ No newline at end of file