Add production Docker deployment

Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-22 06:54:18 +00:00
co-authored by heaust
parent d95e099fa4
commit eb7508d1c1
11 changed files with 286 additions and 6 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.agents
.amp
.idea
.vscode
node_modules
target
dist
core/pkg
server/data
docs
coredocs
vendor
*.log
*.local
+1
View File
@@ -31,6 +31,7 @@ core/pkg
docs/.vitepress/cache/ docs/.vitepress/cache/
docs/.vitepress/dist/ docs/.vitepress/dist/
server/data/ server/data/
yawn-data/
# Amp runtime artifacts # Amp runtime artifacts
.amp/in/ .amp/in/
+70
View File
@@ -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"]
+16
View File
@@ -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 `/playground/{id}/{revision}` URLs. Set `PORT` or `YAWN_DATABASE` to override the default port and
`server/data/yawn.db` database path. `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 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. Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients.
+26
View File
@@ -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:
+10
View File
@@ -53,6 +53,7 @@ func newApplication(store *playgroundStore, packages map[string]memoryAsset, mon
func (app *application) routes() http.Handler { func (app *application) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", app.health)
mux.HandleFunc("/pkg/", app.servePackage) mux.HandleFunc("/pkg/", app.servePackage)
mux.HandleFunc("/api/playgrounds", app.savePlayground) mux.HandleFunc("/api/playgrounds", app.savePlayground)
mux.HandleFunc("/api/playgrounds/", app.getPlayground) mux.HandleFunc("/api/playgrounds/", app.getPlayground)
@@ -66,6 +67,15 @@ func (app *application) routes() http.Handler {
return app.headers(mux) 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 { func (app *application) headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Cross-Origin-Opener-Policy", "same-origin") response.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+9
View File
@@ -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) { func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
app := testApplication(t) app := testApplication(t)
handler := app.routes() handler := app.routes()
+71
View File
@@ -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
+24 -3
View File
@@ -25,10 +25,17 @@ func run() error {
return err return err
} }
log.Print("building core and handles packages") packageDirectory := os.Getenv("YAWN_PACKAGES")
packages, err := buildPackages(root) 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 { 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)) log.Printf("loaded %d package assets into memory", len(packages))
@@ -84,6 +91,20 @@ func run() error {
} }
func repositoryRoot() (string, 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() directory, err := os.Getwd()
if err != nil { if err != nil {
return "", err return "", err
+6 -3
View File
@@ -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 { if err := runBuild(root, "node", filepath.Join(root, "server", "build.mjs"), root, output); err != nil {
return nil, err return nil, err
} }
return loadPackages(output)
}
func loadPackages(directory string) (map[string]memoryAsset, error) {
assets := make(map[string]memoryAsset) assets := make(map[string]memoryAsset)
for _, name := range []string{ for _, name := range []string{
"core.js", "core.js",
@@ -42,21 +45,21 @@ func buildPackages(root string) (map[string]memoryAsset, error) {
"importer-worker.js", "importer-worker.js",
"bvh-worker.js", "bvh-worker.js",
} { } {
asset, err := loadMemoryAsset(filepath.Join(output, name)) asset, err := loadMemoryAsset(filepath.Join(directory, name))
if err != nil { if err != nil {
return nil, err return nil, err
} }
assets[name] = asset 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 { if walkErr != nil {
return walkErr return walkErr
} }
if entry.IsDir() { if entry.IsDir() {
return nil return nil
} }
relative, err := filepath.Rel(filepath.Join(output, "pkg"), path) relative, err := filepath.Rel(filepath.Join(directory, "pkg"), path)
if err != nil { if err != nil {
return err return err
} }
+38
View File
@@ -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)
}
}