diff --git a/Makefile b/Makefile index d1b9ea38..46f1934c 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ PROTOC_GEN_GO_GRPC_VERSION ?= v1.6.1 PROTO_DIR := internal/tasks/proto PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go -.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check split-cloud-check proto check-proto \ +.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check split-cloud-check pages-check proto check-proto \ up upgrade claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \ restart restart-go restart-all infra infra-down app app-down app-logs \ backend forms forms-web consumer worker run dev tracking realtime web \ @@ -82,7 +82,7 @@ cli-check: fmt: gofmt -w ./cmd ./internal -lint: check-migrations join-check split-cloud-check check-dockerfiles +lint: check-migrations join-check split-cloud-check pages-check check-dockerfiles ./scripts/check-forms-mirror.sh $(GO_BIN)/golangci-lint run --timeout=5m @@ -99,6 +99,13 @@ check-migrations: split-cloud-check: @./scripts/check-split-cloud.sh +# web and admin on a static host. They read their configuration from a +# config.js the container entrypoint renders at start, and a static host has no +# container start, so the same script renders it at build time. This runs both +# entrypoints and checks the result is what the app expects. +pages-check: + @./scripts/check-pages-build.sh + # A COPY naming a path no longer in the repo builds green everywhere until it # lands: nothing in `make lint` or the CI workflow builds an image, and # build-push.yml runs only on push to main. Runs in a second; part of `make diff --git a/admin/docker-entrypoint.sh b/admin/docker-entrypoint.sh index dd0c3a7a..8cfe1281 100644 --- a/admin/docker-entrypoint.sh +++ b/admin/docker-entrypoint.sh @@ -1,8 +1,15 @@ #!/bin/sh # Render the runtime config from container env so a single built image serves # any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook). +# +# WARMBLY_CONFIG_OUT moves where it writes, which is how a static host that +# has no container start renders the same file at build time. One definition +# of the key set, so the two paths cannot drift apart. set -eu +CONFIG_OUT="${WARMBLY_CONFIG_OUT:-/usr/share/nginx/html/config.js}" +mkdir -p "$(dirname "$CONFIG_OUT")" + # Values are written into JavaScript string literals, so a double quote, a # backslash or a line break in one would end the literal early and take the # whole config with it, leaving the app with no API_URL at all. Escape rather @@ -11,7 +18,7 @@ js() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n' } -cat > /usr/share/nginx/html/config.js < "$CONFIG_OUT" < +## The dashboard and admin panel on a static host + +`web` and `admin` are static builds, so they do not need a container. Serving them from a CDN is cheaper and faster than running two nginx containers on your container host. + +The one thing to know: both read `window.__WARMBLY_ENV__` from `/config.js`, which the container entrypoint renders from environment at start. A static host has no container start, so the same script renders it at build time instead. + +``` +Build command: pnpm build:pages +Build output: dist +Root directory: web (or admin) +``` + +`build:pages` runs the normal build and then the app's own entrypoint with `WARMBLY_CONFIG_OUT=dist/config.js`. One definition of the key set serves both paths, so the container and the static host cannot drift apart. + +Set these as build environment variables on the host: + +| `web` | `admin` | +|---|---| +| `WARMBLY_API_URL` | `WARMBLY_API_URL` | +| `WARMBLY_APP_URL` | `WARMBLY_DASHBOARD_URL` | +| `WARMBLY_TURNSTILE_KEY` | `WARMBLY_ENV_LABEL` | +| `WARMBLY_SENTRY_DSN` | `WARMBLY_TURNSTILE_KEY` | +| `WARMBLY_POSTHOG_KEY` | `WARMBLY_SENTRY_DSN` | + + +`/* /index.html 200`, copied into the build root. Without it every route below the root returns 404 on a refresh or a shared link, because the file genuinely does not exist and only the app knows the route. It is the static-host equivalent of nginx `try_files`. + + +Whichever origin you serve them from has to be in the backend's `CORS_ALLOW_ORIGINS`, or the dashboard loads and every API call fails preflight. + ## Anything the control plane cannot know `warmbly join` writes `/etc/warmbly/node.env` from the control plane's answer and rewrites it on every join. Next to it, `/etc/warmbly/node.local.env` is created once and never written again, and the container reads it second, so a name repeated there wins. diff --git a/scripts/check-pages-build.sh b/scripts/check-pages-build.sh new file mode 100755 index 00000000..a48b4a87 --- /dev/null +++ b/scripts/check-pages-build.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# Everything CI should know about serving web and admin from a static host. +# +# Those apps read window.__WARMBLY_ENV__ from /config.js, which the container +# entrypoint renders at start. A static host has no container start, so the +# same script renders it at build time via WARMBLY_CONFIG_OUT. Both paths share +# one definition of the key set precisely so they cannot drift, and this checks +# the sharing still works. +# +# It runs the real entrypoints rather than reading them: a config.js that is +# subtly malformed still looks fine in a diff and leaves the app with no API +# URL at runtime, which presents as a blank page and nothing in the logs. +set -eu + +fail() { printf 'check-pages-build: %s\n' "$*" >&2; exit 1; } +ok() { printf ' ok %s\n' "$*"; } +skip() { printf ' -- %s\n' "$*"; } + +WORK=$(mktemp -d) +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT INT TERM + +for app in web admin; do + entry="$app/docker-entrypoint.sh" + [ -f "$entry" ] || fail "$entry not found (run from the repository root)" + + if command -v dash >/dev/null 2>&1; then + dash -n "$entry" || fail "dash -n failed on $entry" + else + sh -n "$entry" || fail "sh -n failed on $entry" + fi + + # The container path must stay exactly what it was: this file is the nginx + # entrypoint hook, and a changed default silently stops the image working. + grep -q '/usr/share/nginx/html/config.js' "$entry" \ + || fail "$entry no longer defaults to the nginx path; the container image would serve no config" + + out="$WORK/$app/config.js" + WARMBLY_CONFIG_OUT="$out" \ + WARMBLY_API_URL="https://api.example.com" \ + WARMBLY_APP_URL="https://app.example.com" \ + WARMBLY_DASHBOARD_URL="https://app.example.com" \ + sh "$entry" || fail "$entry failed to render to WARMBLY_CONFIG_OUT" + + [ -f "$out" ] || fail "$entry ignored WARMBLY_CONFIG_OUT; a static build would ship no config.js" + + grep -q 'window.__WARMBLY_ENV__' "$out" \ + || fail "$app config.js does not define window.__WARMBLY_ENV__" + grep -q 'API_URL: "https://api.example.com"' "$out" \ + || fail "$app config.js did not pick up WARMBLY_API_URL" + + if command -v node >/dev/null 2>&1; then + node --check "$out" >/dev/null 2>&1 \ + || fail "$app config.js is not valid JavaScript; the app would load with no configuration at all" + fi + + # Vite copies public/ to the build root, so this is what lands beside + # index.html. Without it every deep link 404s on a static host. + [ -f "$app/public/_redirects" ] || fail "$app/public/_redirects is missing; deep links would 404 on a static host" + grep -qE '^/\*[[:space:]]+/index\.html[[:space:]]+200' "$app/public/_redirects" \ + || fail "$app/public/_redirects has no SPA rule serving index.html with 200" + + # The build a static host runs has to exist and has to render the config. + grep -q '"build:pages"' "$app/package.json" \ + || fail "$app/package.json has no build:pages script" + grep -q 'WARMBLY_CONFIG_OUT=dist/config.js' "$app/package.json" \ + || fail "$app build:pages does not render config.js into the build output" + + ok "$app renders config.js, has the SPA rule, and keeps the container default" +done + +if ! command -v node >/dev/null 2>&1; then + skip "node not installed; skipped the JavaScript syntax check" +fi + +printf 'check-pages-build: all checks passed\n' diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh index 58309496..6b601d76 100644 --- a/web/docker-entrypoint.sh +++ b/web/docker-entrypoint.sh @@ -1,8 +1,15 @@ #!/bin/sh # Render the runtime config from container env so a single built image serves # any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook). +# +# WARMBLY_CONFIG_OUT moves where it writes, which is how a static host that +# has no container start renders the same file at build time. One definition +# of the key set, so the two paths cannot drift apart. set -eu +CONFIG_OUT="${WARMBLY_CONFIG_OUT:-/usr/share/nginx/html/config.js}" +mkdir -p "$(dirname "$CONFIG_OUT")" + # Values are written into JavaScript string literals, so a double quote, a # backslash or a line break in one would end the literal early and take the # whole config with it, leaving the app with no API_URL at all. Escape rather @@ -11,7 +18,7 @@ js() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r\n' } -cat > /usr/share/nginx/html/config.js < "$CONFIG_OUT" <