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
+10
View File
@@ -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")
+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) {
app := testApplication(t)
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
}
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
+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 {
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
}
+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)
}
}