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:
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.agents
|
||||
.amp
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
target
|
||||
dist
|
||||
core/pkg
|
||||
server/data
|
||||
docs
|
||||
coredocs
|
||||
vendor
|
||||
*.log
|
||||
*.local
|
||||
@@ -31,6 +31,7 @@ core/pkg
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
server/data/
|
||||
yawn-data/
|
||||
|
||||
# Amp runtime artifacts
|
||||
.amp/in/
|
||||
|
||||
+70
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Executable
+71
@@ -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
|
||||
+23
-2
@@ -25,10 +25,17 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
packageDirectory := os.Getenv("YAWN_PACKAGES")
|
||||
var packages map[string]memoryAsset
|
||||
if packageDirectory == "" {
|
||||
log.Print("building core and handles packages")
|
||||
packages, err := buildPackages(root)
|
||||
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
|
||||
|
||||
+6
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user