From eb7508d1c14a82ab43332ece50a1bbdf56f1beb3 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 22 Aug 2026 06:54:18 +0000 Subject: [PATCH] Add production Docker deployment Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a Co-authored-by: Heaust Azure --- .dockerignore | 15 +++++++++ .gitignore | 1 + Dockerfile | 70 ++++++++++++++++++++++++++++++++++++++++ README.md | 16 ++++++++++ compose.yaml | 26 +++++++++++++++ server/app.go | 10 ++++++ server/app_test.go | 9 ++++++ server/deploy-docker.sh | 71 +++++++++++++++++++++++++++++++++++++++++ server/main.go | 27 ++++++++++++++-- server/packages.go | 9 ++++-- server/packages_test.go | 38 ++++++++++++++++++++++ 11 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100755 server/deploy-docker.sh create mode 100644 server/packages_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6386e0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.agents +.amp +.idea +.vscode +node_modules +target +dist +core/pkg +server/data +docs +coredocs +vendor +*.log +*.local diff --git a/.gitignore b/.gitignore index b1c3de8..79ea7f8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ core/pkg docs/.vitepress/cache/ docs/.vitepress/dist/ server/data/ +yawn-data/ # Amp runtime artifacts .amp/in/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..878b9ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm AS package-builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential ca-certificates curl libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable +ENV PATH="/root/.cargo/bin:${PATH}" +RUN cargo install wasm-pack --version 0.15.0 --locked + +WORKDIR /src +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY .cargo ./.cargo +RUN toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" \ + && rustup toolchain install "$toolchain" --profile minimal --component rust-src --target wasm32-unknown-unknown + +COPY package.json package-lock.json ./ +COPY core/package.json ./core/package.json +COPY addons/handles/package.json ./addons/handles/package.json +RUN --mount=type=cache,target=/root/.npm npm ci --include=dev + +COPY core ./core +COPY addons/handles ./addons/handles +COPY server/build.mjs ./server/build.mjs +RUN --mount=type=cache,target=/src/target mkdir -p /out \ + && wasm-pack build core --target web --out-dir /out/pkg --release \ + && node server/build.mjs /src /out + +FROM golang:1.24-bookworm AS server-builder + +WORKDIR /src +COPY go.work ./ +COPY server/go.mod server/go.sum ./server/ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY server ./server +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=1 go test ./server/... \ + && CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/yawn-server ./server + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 yawn \ + && useradd --system --uid 10001 --gid yawn --home-dir /app --create-home yawn \ + && mkdir -p /data \ + && chown yawn:yawn /data + +WORKDIR /app +COPY --from=server-builder --chown=yawn:yawn /out/yawn-server /app/yawn-server +COPY --from=package-builder --chown=yawn:yawn /out /app/packages +COPY --from=package-builder --chown=yawn:yawn /src/node_modules/monaco-editor/min /app/node_modules/monaco-editor/min + +ENV PORT=5002 \ + YAWN_ROOT=/app \ + YAWN_PACKAGES=/app/packages \ + YAWN_DATABASE=/data/yawn.db + +USER yawn:yawn +VOLUME ["/data"] +EXPOSE 5002 +STOPSIGNAL SIGTERM +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/healthz" || exit 1 + +ENTRYPOINT ["/app/yawn-server"] diff --git a/README.md b/README.md index 6daa8dc..1bd4984 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,22 @@ as a separate module dependency instead of bundling it. Map and import both URLs `/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and `server/data/yawn.db` database path. +## VPS deployment + +Clone the repository to `~/yawn` on a VPS with Docker and the Compose plugin, then run: + +```sh +cd ~/yawn +./server/deploy-docker.sh +``` + +The deploy script requires a clean `main`, fast-forwards it to `origin/main`, builds the production +image, starts it with Docker Compose, and waits for the health check. Core, Handles, workers, and +WASM are compiled while building the image; the runtime container only holds the Go server, +prebuilt package assets, Monaco, and the persistent SQLite volume. Configure the port and database +path in `compose.yaml`. The default port binding is private to the VPS at `127.0.0.1:5002`; put +Caddy or nginx in front of it for HTTPS. Follow logs with `docker compose logs -f yawn`. + Run the previous VitePress documentation site with `npm start`. Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..735f900 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,26 @@ +services: + yawn: + build: + context: . + dockerfile: Dockerfile + image: yawn:local + restart: unless-stopped + init: true + ports: + - "127.0.0.1:5002:5002" + environment: + PORT: "5002" + YAWN_DATABASE: /data/yawn.db + volumes: + - yawn-data:/data + read_only: true + tmpfs: + - /tmp:size=64m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 15s + +volumes: + yawn-data: diff --git a/server/app.go b/server/app.go index a5eb1cb..33f7038 100644 --- a/server/app.go +++ b/server/app.go @@ -53,6 +53,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon func (app *application) routes() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/healthz", app.health) mux.HandleFunc("/pkg/", app.servePackage) mux.HandleFunc("/api/playgrounds", app.savePlayground) mux.HandleFunc("/api/playgrounds/", app.getPlayground) @@ -66,6 +67,15 @@ func (app *application) routes() http.Handler { return app.headers(mux) } +func (app *application) health(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet && request.Method != http.MethodHead { + response.Header().Set("Allow", "GET, HEAD") + http.Error(response, "method not allowed", http.StatusMethodNotAllowed) + return + } + response.WriteHeader(http.StatusNoContent) +} + func (app *application) headers(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { response.Header().Set("Cross-Origin-Opener-Policy", "same-origin") diff --git a/server/app_test.go b/server/app_test.go index d87c049..2f85059 100644 --- a/server/app_test.go +++ b/server/app_test.go @@ -57,6 +57,15 @@ func TestPackageIsServedFromMemory(t *testing.T) { } } +func TestHealth(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/healthz", nil) + response := httptest.NewRecorder() + testApplication(t).routes().ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d", response.Code) + } +} + func TestSaveAndLoadPlaygroundRevision(t *testing.T) { app := testApplication(t) handler := app.routes() diff --git a/server/deploy-docker.sh b/server/deploy-docker.sh new file mode 100755 index 0000000..e8ae569 --- /dev/null +++ b/server/deploy-docker.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +expected_repository="$HOME/yawn" + +if [[ "$repository" != "$expected_repository" ]]; then + printf 'Expected the repository at %s, found %s\n' "$expected_repository" "$repository" >&2 + exit 1 +fi + +for command in git docker; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$command" >&2 + exit 1 + fi +done +if ! docker compose version >/dev/null 2>&1; then + printf 'Docker Compose v2 is required.\n' >&2 + exit 1 +fi + +cd "$repository" + +if [[ "$(git branch --show-current)" != "main" ]]; then + printf 'Deployments must run from the main branch.\n' >&2 + exit 1 +fi +if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then + printf 'Refusing to deploy a checkout with local changes.\n' >&2 + exit 1 +fi + +printf 'Updating main...\n' +git fetch origin main +fetched="$(git rev-parse FETCH_HEAD)" +git merge --ff-only "$fetched" +if [[ "$(git rev-parse HEAD)" != "$fetched" ]]; then + printf 'HEAD does not match origin/main after updating.\n' >&2 + exit 1 +fi + +printf 'Building the production image...\n' +docker compose build --pull yawn +docker compose up -d --remove-orphans yawn + +container="$(docker compose ps -q yawn)" +if [[ -z "$container" ]]; then + printf 'Docker Compose did not create the Yawn container.\n' >&2 + exit 1 +fi + +printf 'Waiting for Yawn to become healthy...\n' +for _ in {1..60}; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container")" + case "$status" in + healthy) + printf 'The Yawn container is healthy.\n' + exit 0 + ;; + unhealthy | exited | dead) + docker compose logs --tail 50 yawn >&2 + exit 1 + ;; + esac + sleep 2 +done + +printf 'Yawn did not become healthy before the timeout.\n' >&2 +docker compose logs --tail 50 yawn >&2 +exit 1 diff --git a/server/main.go b/server/main.go index 47ac836..513db2f 100644 --- a/server/main.go +++ b/server/main.go @@ -25,10 +25,17 @@ func run() error { return err } - log.Print("building core and handles packages") - packages, err := buildPackages(root) + packageDirectory := os.Getenv("YAWN_PACKAGES") + var packages map[string]memoryAsset + if packageDirectory == "" { + log.Print("building core and handles packages") + packages, err = buildPackages(root) + } else { + log.Printf("loading prebuilt packages from %s", packageDirectory) + packages, err = loadPackages(packageDirectory) + } if err != nil { - return fmt.Errorf("build packages: %w", err) + return fmt.Errorf("prepare packages: %w", err) } log.Printf("loaded %d package assets into memory", len(packages)) @@ -84,6 +91,20 @@ func run() error { } func repositoryRoot() (string, error) { + if configured := os.Getenv("YAWN_ROOT"); configured != "" { + root, err := filepath.Abs(configured) + if err != nil { + return "", err + } + info, err := os.Stat(root) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("YAWN_ROOT is not a directory: %s", root) + } + return root, nil + } directory, err := os.Getwd() if err != nil { return "", err diff --git a/server/packages.go b/server/packages.go index 0b42874..edd8c3e 100644 --- a/server/packages.go +++ b/server/packages.go @@ -33,7 +33,10 @@ func buildPackages(root string) (map[string]memoryAsset, error) { if err := runBuild(root, "node", filepath.Join(root, "server", "build.mjs"), root, output); err != nil { return nil, err } + return loadPackages(output) +} +func loadPackages(directory string) (map[string]memoryAsset, error) { assets := make(map[string]memoryAsset) for _, name := range []string{ "core.js", @@ -42,21 +45,21 @@ func buildPackages(root string) (map[string]memoryAsset, error) { "importer-worker.js", "bvh-worker.js", } { - asset, err := loadMemoryAsset(filepath.Join(output, name)) + asset, err := loadMemoryAsset(filepath.Join(directory, name)) if err != nil { return nil, err } assets[name] = asset } - err = filepath.WalkDir(filepath.Join(output, "pkg"), func(path string, entry fs.DirEntry, walkErr error) error { + err := filepath.WalkDir(filepath.Join(directory, "pkg"), func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.IsDir() { return nil } - relative, err := filepath.Rel(filepath.Join(output, "pkg"), path) + relative, err := filepath.Rel(filepath.Join(directory, "pkg"), path) if err != nil { return err } diff --git a/server/packages_test.go b/server/packages_test.go new file mode 100644 index 0000000..f4eb78c --- /dev/null +++ b/server/packages_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadPackages(t *testing.T) { + directory := t.TempDir() + for _, name := range []string{ + "core.js", + "handles.js", + "worker.js", + "importer-worker.js", + "bvh-worker.js", + filepath.Join("pkg", "yawn_core_bg.wasm"), + } { + path := filepath.Join(directory, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + + packages, err := loadPackages(directory) + if err != nil { + t.Fatal(err) + } + if len(packages) != 6 { + t.Fatalf("package count = %d", len(packages)) + } + if content := string(packages["pkg/yawn_core_bg.wasm"].content); content != filepath.Join("pkg", "yawn_core_bg.wasm") { + t.Fatalf("WASM content = %q", content) + } +}