Skip to content

Commit 776bf8e

Browse files
Merge pull request #5 from OneBusAway/hooksyml
fix(docker): auto-init on empty /data so first Render deploy can boot
2 parents 9a0f3f0 + 28b84b4 commit 776bf8e

4 files changed

Lines changed: 101 additions & 24 deletions

File tree

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ RUN apk add --no-cache ca-certificates \
4747

4848
COPY --from=builder /out/hooks /usr/local/bin/hooks
4949
COPY --from=builder /out/hooksctl /usr/local/bin/hooksctl
50+
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
5051

5152
USER hooks
5253
WORKDIR /data
@@ -64,4 +65,4 @@ EXPOSE 8080
6465
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
6566
CMD wget -qO- "http://127.0.0.1${HOOKS_LISTEN_ADDR:-:8080}/healthz" >/dev/null 2>&1 || exit 1
6667

67-
ENTRYPOINT ["/usr/local/bin/hooks"]
68+
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

docker-entrypoint.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/bin/sh
2+
# First-boot bootstrap for the hooks server image.
3+
#
4+
# When /data has neither hooks.yaml nor hooks.db (a freshly-mounted persistent
5+
# volume), run `hooks init --dir /data` so the server can start. Without this,
6+
# Render Blueprint deploys crash-loop on the very first boot — the volume is
7+
# empty, the server can't read hooks.yaml, and Render's Shell tab is gated on
8+
# a running instance, so the documented recovery path is unreachable.
9+
#
10+
# The auto-init prints a one-time admin token and bootstrap signup URL to
11+
# stdout. On Render those land in the service log (private to your team).
12+
# Treat both as secrets.
13+
#
14+
# Subcommands (init/invite/prune/verify/help) bypass the bootstrap — they're
15+
# already past the "fresh volume" case or want explicit control.
16+
17+
set -e
18+
19+
case "${1:-}" in
20+
init|invite|prune|verify|help|-h|--help)
21+
;;
22+
*)
23+
if [ ! -f /data/hooks.yaml ] && [ ! -f /data/hooks.db ]; then
24+
/usr/local/bin/hooks init --dir /data
25+
fi
26+
;;
27+
esac
28+
29+
exec /usr/local/bin/hooks "$@"

dockertest/docker_test.go

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -180,24 +180,52 @@ func TestImageInitScaffold(t *testing.T) {
180180
}
181181
}
182182

183+
// TestImageFirstBootAutoInit boots the server against an empty /data with no
184+
// prior `hooks init` and verifies the entrypoint scaffolds hooks.yaml +
185+
// hooks.db, prints the one-time admin token, and reaches /healthz. Models the
186+
// "fresh Render Blueprint deploy on an empty persistent disk" scenario where
187+
// the operator can't shell in to run init manually because the service hasn't
188+
// reached a healthy state yet.
189+
//
190+
// We capture container logs (which will contain the admin token) and never
191+
// echo them — only check token-shape via extractAdminToken, then redact
192+
// before any t.Fatalf.
193+
func TestImageFirstBootAutoInit(t *testing.T) {
194+
skipIfNoDocker(t)
195+
196+
dir := t.TempDir()
197+
if err := os.Chmod(dir, 0o777); err != nil {
198+
t.Fatalf("chmod tempdir: %v", err)
199+
}
200+
201+
containerName := fmt.Sprintf("hooks-dockertest-fb-%d", time.Now().UnixNano())
202+
addr := runImageDetached(t, containerName, dir)
203+
if err := waitForHealthz(addr, 60*time.Second); err != nil {
204+
// Logs may contain the admin token from auto-init; never echo raw.
205+
t.Fatalf("/healthz never returned 200: %v (logs redacted: may contain admin token)", err)
206+
}
207+
208+
for _, name := range []string{"hooks.yaml", "hooks.db"} {
209+
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
210+
t.Fatalf("expected %s in /data after first-boot auto-init: %v", name, err)
211+
}
212+
}
213+
214+
logs := dockerLogs(containerName)
215+
token := extractAdminToken([]byte(logs))
216+
if token == "" {
217+
// Don't echo logs — first-boot init may have printed the token even
218+
// if our parser missed it.
219+
t.Fatal("first-boot did not print an admin-token line (logs redacted)")
220+
}
221+
}
222+
183223
func TestImageServesHealthEndpoints(t *testing.T) {
184224
skipIfNoDocker(t)
185225
dir := scaffoldDataDir(t)
186226

187227
containerName := fmt.Sprintf("hooks-dockertest-%d", time.Now().UnixNano())
188-
out, err := exec.Command("docker", "run", "-d", "--rm",
189-
"--name", containerName,
190-
"-v", dir+":/data",
191-
"-e", "RENDER_WEBHOOK_SECRET=stub-for-tests",
192-
"-p", "0:8080",
193-
imageTag,
194-
).CombinedOutput()
195-
if err != nil {
196-
t.Fatalf("docker run: %v\n%s", err, out)
197-
}
198-
t.Cleanup(func() { cleanupContainer(t, containerName) })
199-
200-
addr := "http://127.0.0.1:" + hostPort(t, containerName, "8080/tcp")
228+
addr := runImageDetached(t, containerName, dir)
201229
if err := waitForHealthz(addr, 60*time.Second); err != nil {
202230
t.Fatalf("/healthz never returned 200: %v\nlogs:\n%s", err, dockerLogs(containerName))
203231
}
@@ -492,6 +520,30 @@ func TestImageInitFailsClearlyOn0o755HostDir(t *testing.T) {
492520
}
493521
}
494522

523+
// runImageDetached starts the standard test envelope (image, /data
524+
// bind-mounted from dir, stub RENDER_WEBHOOK_SECRET, ephemeral host port
525+
// → 8080, detached + auto-remove) and registers container cleanup on t.
526+
// Returns the http://127.0.0.1:<port> base URL the test should hit.
527+
//
528+
// Tests that need different env, no --rm, no port mapping, or a non-server
529+
// invocation should call docker directly rather than thread parameters
530+
// through here — every variant added here costs more than it saves.
531+
func runImageDetached(t *testing.T, name, dir string) string {
532+
t.Helper()
533+
out, err := exec.Command("docker", "run", "-d", "--rm",
534+
"--name", name,
535+
"-v", dir+":/data",
536+
"-e", "RENDER_WEBHOOK_SECRET=stub-for-tests",
537+
"-p", "0:8080",
538+
imageTag,
539+
).CombinedOutput()
540+
if err != nil {
541+
t.Fatalf("docker run: %v\n%s", err, out)
542+
}
543+
t.Cleanup(func() { cleanupContainer(t, name) })
544+
return "http://127.0.0.1:" + hostPort(t, name, "8080/tcp")
545+
}
546+
495547
// waitForHealthz polls /healthz on the running container until it returns
496548
// 200 or the deadline expires. Server-side errors (5xx) are preserved
497549
// across iterations — if the server ever returned 500 then died, the

docs/quickstart.md

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,23 +89,18 @@ A Dockerfile-level `HEALTHCHECK` polls `/healthz`; in front of a load balancer,
8989

9090
The repo also includes a `render.yaml` Blueprint. To deploy:
9191

92-
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`.
93-
2. After the first deploy, in the service's **Environment** tab set:
94-
- `RENDER_WEBHOOK_SECRET` — the per-webhook signing secret Render gave you when you created the webhook in step 5 below.
95-
- `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.
96-
3. Open a shell into the service (Render dashboard → **Shell**) and bootstrap:
97-
```sh
98-
hooks init --server-url "$HOOKS_PUBLIC_URL"
99-
```
100-
Save the printed admin token and bootstrap signup URL. Restart the service so it picks up the new DB.
92+
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:
93+
- `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.)
94+
- `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.
95+
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.
10196

10297
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.
10398

10499
## 4. Claim the first admin account
105100

106101
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.
107102

108-
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.
103+
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.
109104

110105
## 5. Register the webhook with Render
111106

0 commit comments

Comments
 (0)