Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
29 changes: 29 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
78 changes: 65 additions & 13 deletions dockertest/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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:<port> 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
Expand Down
15 changes: 5 additions & 10 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,18 @@ 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.

## 4. Claim the first admin account

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

Expand Down
Loading