diff --git a/Dockerfile b/Dockerfile index 986701d..ac4e4d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,6 +47,7 @@ RUN apk add --no-cache ca-certificates \ COPY --from=builder /out/hooks /usr/local/bin/hooks COPY --from=builder /out/hooksctl /usr/local/bin/hooksctl +COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh USER hooks WORKDIR /data @@ -64,4 +65,4 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- "http://127.0.0.1${HOOKS_LISTEN_ADDR:-:8080}/healthz" >/dev/null 2>&1 || exit 1 -ENTRYPOINT ["/usr/local/bin/hooks"] +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..ce65ec5 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# First-boot bootstrap for the hooks server image. +# +# When /data has neither hooks.yaml nor hooks.db (a freshly-mounted persistent +# volume), run `hooks init --dir /data` so the server can start. Without this, +# Render Blueprint deploys crash-loop on the very first boot — the volume is +# empty, the server can't read hooks.yaml, and Render's Shell tab is gated on +# a running instance, so the documented recovery path is unreachable. +# +# The auto-init prints a one-time admin token and bootstrap signup URL to +# stdout. On Render those land in the service log (private to your team). +# Treat both as secrets. +# +# Subcommands (init/invite/prune/verify/help) bypass the bootstrap — they're +# already past the "fresh volume" case or want explicit control. + +set -e + +case "${1:-}" in + init|invite|prune|verify|help|-h|--help) + ;; + *) + if [ ! -f /data/hooks.yaml ] && [ ! -f /data/hooks.db ]; then + /usr/local/bin/hooks init --dir /data + fi + ;; +esac + +exec /usr/local/bin/hooks "$@" diff --git a/dockertest/docker_test.go b/dockertest/docker_test.go index 5d44df5..009ae61 100644 --- a/dockertest/docker_test.go +++ b/dockertest/docker_test.go @@ -180,24 +180,52 @@ func TestImageInitScaffold(t *testing.T) { } } +// TestImageFirstBootAutoInit boots the server against an empty /data with no +// prior `hooks init` and verifies the entrypoint scaffolds hooks.yaml + +// hooks.db, prints the one-time admin token, and reaches /healthz. Models the +// "fresh Render Blueprint deploy on an empty persistent disk" scenario where +// the operator can't shell in to run init manually because the service hasn't +// reached a healthy state yet. +// +// We capture container logs (which will contain the admin token) and never +// echo them — only check token-shape via extractAdminToken, then redact +// before any t.Fatalf. +func TestImageFirstBootAutoInit(t *testing.T) { + skipIfNoDocker(t) + + dir := t.TempDir() + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatalf("chmod tempdir: %v", err) + } + + containerName := fmt.Sprintf("hooks-dockertest-fb-%d", time.Now().UnixNano()) + addr := runImageDetached(t, containerName, dir) + if err := waitForHealthz(addr, 60*time.Second); err != nil { + // Logs may contain the admin token from auto-init; never echo raw. + t.Fatalf("/healthz never returned 200: %v (logs redacted: may contain admin token)", err) + } + + for _, name := range []string{"hooks.yaml", "hooks.db"} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("expected %s in /data after first-boot auto-init: %v", name, err) + } + } + + logs := dockerLogs(containerName) + token := extractAdminToken([]byte(logs)) + if token == "" { + // Don't echo logs — first-boot init may have printed the token even + // if our parser missed it. + t.Fatal("first-boot did not print an admin-token line (logs redacted)") + } +} + func TestImageServesHealthEndpoints(t *testing.T) { skipIfNoDocker(t) dir := scaffoldDataDir(t) containerName := fmt.Sprintf("hooks-dockertest-%d", time.Now().UnixNano()) - out, err := exec.Command("docker", "run", "-d", "--rm", - "--name", containerName, - "-v", dir+":/data", - "-e", "RENDER_WEBHOOK_SECRET=stub-for-tests", - "-p", "0:8080", - imageTag, - ).CombinedOutput() - if err != nil { - t.Fatalf("docker run: %v\n%s", err, out) - } - t.Cleanup(func() { cleanupContainer(t, containerName) }) - - addr := "http://127.0.0.1:" + hostPort(t, containerName, "8080/tcp") + addr := runImageDetached(t, containerName, dir) if err := waitForHealthz(addr, 60*time.Second); err != nil { t.Fatalf("/healthz never returned 200: %v\nlogs:\n%s", err, dockerLogs(containerName)) } @@ -492,6 +520,30 @@ func TestImageInitFailsClearlyOn0o755HostDir(t *testing.T) { } } +// runImageDetached starts the standard test envelope (image, /data +// bind-mounted from dir, stub RENDER_WEBHOOK_SECRET, ephemeral host port +// → 8080, detached + auto-remove) and registers container cleanup on t. +// Returns the http://127.0.0.1: base URL the test should hit. +// +// Tests that need different env, no --rm, no port mapping, or a non-server +// invocation should call docker directly rather than thread parameters +// through here — every variant added here costs more than it saves. +func runImageDetached(t *testing.T, name, dir string) string { + t.Helper() + out, err := exec.Command("docker", "run", "-d", "--rm", + "--name", name, + "-v", dir+":/data", + "-e", "RENDER_WEBHOOK_SECRET=stub-for-tests", + "-p", "0:8080", + imageTag, + ).CombinedOutput() + if err != nil { + t.Fatalf("docker run: %v\n%s", err, out) + } + t.Cleanup(func() { cleanupContainer(t, name) }) + return "http://127.0.0.1:" + hostPort(t, name, "8080/tcp") +} + // waitForHealthz polls /healthz on the running container until it returns // 200 or the deadline expires. Server-side errors (5xx) are preserved // across iterations — if the server ever returned 500 then died, the diff --git a/docs/quickstart.md b/docs/quickstart.md index 4f1e6a9..796539f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -89,15 +89,10 @@ A Dockerfile-level `HEALTHCHECK` polls `/healthz`; in front of a load balancer, The repo also includes a `render.yaml` Blueprint. To deploy: -1. Push (or fork) this repo to GitHub, then in Render: **New → Blueprint** and select the repo. Render reads `render.yaml` and provisions a Docker web service plus a 1 GiB persistent disk mounted at `/data`. -2. After the first deploy, in the service's **Environment** tab set: - - `RENDER_WEBHOOK_SECRET` — the per-webhook signing secret Render gave you when you created the webhook in step 5 below. - - `HOOKS_PUBLIC_URL` — your service's external URL, e.g. `https://hooks-abc1.onrender.com`. Used to build the bootstrap signup link and device-pairing pages. -3. Open a shell into the service (Render dashboard → **Shell**) and bootstrap: - ```sh - hooks init --server-url "$HOOKS_PUBLIC_URL" - ``` - Save the printed admin token and bootstrap signup URL. Restart the service so it picks up the new DB. +1. In Render: **New → Blueprint** and select this repo (fork first if you want autoDeploy on your own pushes). Render reads `render.yaml` and provisions a Docker web service plus a 1 GiB persistent disk mounted at `/data`. Before the first deploy, set the two `sync: false` env vars in the service's **Environment** tab: + - `RENDER_WEBHOOK_SECRET` — the per-webhook signing secret Render gives you when you create the webhook in step 5 below. (Use a placeholder for now and rotate it once the webhook exists.) + - `HOOKS_PUBLIC_URL` — your service's external URL, e.g. `https://hooks-abc1.onrender.com`. Used to build the bootstrap signup link printed during first-boot init. +2. Trigger a deploy. The container's entrypoint detects an empty `/data`, runs `hooks init --dir /data` automatically, and prints the one-time admin token plus the bootstrap signup URL to the service **Logs**. Copy both from the log lines (treat them as secrets — the token is shown only once). The server then starts normally. The server honors `$PORT` (which Render injects) automatically, so the Blueprint only wires `/readyz` as the health check — no listen-address knob to keep in sync. Both `hooks` and `hooksctl` are on `$PATH` in the shell, so token rotation, push subscription management, and pruning all work without leaving Render. @@ -105,7 +100,7 @@ The server honors `$PORT` (which Render injects) automatically, so the Blueprint Open the bootstrap signup URL from step 2 in a browser. Pick an email, name, and password (≥ 12 characters; must not contain your email or its local-part). Submitting the form consumes the bootstrap invite, signs you into the inspector at `/inspector`, and the URL returns 409 from then on. -If the link expires before you use it, re-run `hooks init` against the still-empty DB to mint a fresh 24-hour invite. Once any user exists, the bootstrap path is closed — invite teammates from `/inspector/users` (or `POST /api/invites`) instead. +If the link expires before you use it, open the service's **Shell** (now available since the deploy is healthy) and re-run `hooks init --force --server-url "$HOOKS_PUBLIC_URL"` to mint a fresh 24-hour invite. Once any user exists, the bootstrap path is closed — invite teammates from `/inspector/users` (or `POST /api/invites`) instead. ## 5. Register the webhook with Render