From 8669417b9a1c7460b889a7f9a02150828db6e749 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 25 May 2026 10:22:36 -0700 Subject: [PATCH 1/3] build: add Render deployment artifacts (Docker cron job + one-off jobs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy oba-validator to Render as a Docker cron job scheduled for Feb 29 (0 2 29 2 *) so it effectively never runs on its own; real validations are launched on demand as Render one-off jobs. Render splits a job's startCommand on whitespace with no shell and uses the first token as the executable, so the JSON config is base64-encoded into one token and decoded by entrypoint.sh (mirroring obacloud's existing one-off-job pattern). No API key is baked in โ€” keys are per-server and ride inside the config. - Dockerfile: multi-stage pure-Go build, alpine + ca-certificates - entrypoint.sh: base64-decodes the config; raw JSON ("{"-prefixed) passed through - render.yaml: Blueprint for the Docker cron job (no env vars by design) - .dockerignore, Makefile docker-build target, README + design spec ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .dockerignore | 6 + Dockerfile | 34 ++++ Makefile | 7 +- README.md | 61 ++++++- .../2026-05-25-render-deployment-design.md | 158 ++++++++++++++++++ entrypoint.sh | 28 ++++ render.yaml | 14 ++ 7 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docs/superpowers/specs/2026-05-25-render-deployment-design.md create mode 100644 entrypoint.sh create mode 100644 render.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..921723f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +bin/ +.git/ +.github/ +docs/ +example_configs/ +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1747674 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Stage 1: build the Go binary +FROM golang:1-alpine AS builder + +WORKDIR /build + +# Cache module downloads across builds +COPY go.mod go.sum ./ +RUN go mod download + +# Build a static binary (no cgo) for the runtime image +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o oba-validator ./cmd/oba-validator + +# Stage 2: minimal runtime +FROM alpine:3 + +# HTTPS to the OBA API and the GTFS / GTFS-realtime feeds needs CA certificates. +RUN apk add --no-cache ca-certificates + +WORKDIR /app +COPY --from=builder /build/oba-validator /app/oba-validator +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# entrypoint.sh base64-decodes its argument into the config JSON before invoking +# the validator. This is required because a Render one-off job's startCommand is +# split on whitespace and passed as argv (no shell), so the JSON โ€” which has +# spaces and special characters โ€” must be base64-encoded by the caller: +# `/app/entrypoint.sh `. (Render uses the startCommand's first +# token as the executable, hence naming entrypoint.sh explicitly.) See "Deploying +# to Render" in the README. No API key is baked in โ€” keys are per-server and +# travel in the config. +ENTRYPOINT ["/app/entrypoint.sh"] +CMD [] diff --git a/Makefile b/Makefile index 30afedd..dc4736e 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ BINARY := oba-validator PKG := ./cmd/oba-validator BIN := bin/$(BINARY) +IMAGE := oba-validator -.PHONY: all build test test-live vet fmt run clean tidy install +.PHONY: all build test test-live vet fmt run clean tidy install docker-build all: build @@ -38,6 +39,10 @@ tidy: install: go install $(PKG) +## docker-build: build the deployment image (see render.yaml) +docker-build: + docker build -t $(IMAGE) . + ## clean: remove build artifacts clean: rm -rf bin diff --git a/README.md b/README.md index 2e4c069..f0cc94f 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,63 @@ os.Exit(rep.ExitCode()) ## Development - make build # compile to bin/oba-validator - make test # run unit tests (no network) + make build # compile to bin/oba-validator + make test # run unit tests (no network) make run ARGS=config.json - make test-live # env-gated live test against the real server + make test-live # env-gated live test against the real server + make docker-build # build the deployment image Run `make` with no target to build. See the `Makefile` for all targets. -See `docs/superpowers/specs/2026-05-24-oba-validator-design.md` for the full -design. +## Deploying to Render + +The validator deploys to [Render](https://render.com) as a Docker **cron job** +whose schedule (`0 2 29 2 *`, Feb 29 02:00 UTC) makes it effectively never run on +its own. Real validations are launched on demand as **one-off jobs** against the +service, with the entire config โ€” including `apiKey` โ€” passed as the start +command. Nothing server-specific is baked into the image, so one deployment can +validate any OBA server. + +Build the image locally to verify it (the container accepts raw JSON directly): + + make docker-build + docker run --rm oba-validator '{"obaServerURL":"https://api.pugetsound.onebusaway.org","apiKey":"org.onebusaway.iphone","dataSources":[{"agencyMapping":{"KCM":"1"},"staticGtfsFeedURL":"https://metro.kingcounty.gov/GTFS/google_transit.zip","vehiclePositionsURL":"https://s3.amazonaws.com/kcm-alerts-realtime-prod/vehiclepositions.pb"}]}' + +Deploy by pointing a Render Blueprint at `render.yaml`, or create the cron job by +hand in the dashboard (Docker runtime, the schedule above, no environment +variables needed). + +### Triggering a validation (one-off job) + +Render runs a one-off job's start command by **splitting it on whitespace and +passing it as argv โ€” there is no shell**, and the first token is used as the +executable. A JSON config can't be passed inline (it has spaces and special +characters), so **base64-encode the config** into a single token and let the +image's `entrypoint.sh` decode it. The start command is: + + /app/entrypoint.sh + +Produce the token from your config (note the `apiKey` lives *in* the config โ€” it +is never baked into the image, since keys differ per server): + + printf '%s' '{"obaServerURL":"https://api.example.org","apiKey":"your-key","dataSources":[{"agencyMapping":{"X":"1"},"staticGtfsFeedURL":"https://.../gtfs.zip","vehiclePositionsURL":"https://.../vp.pb"}]}' | base64 | tr -d '\n' + +Trigger it via the API (base64 is plain `[A-Za-z0-9+/=]`, so no escaping needed): + + curl --request POST 'https://api.render.com/v1/services//jobs' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{"startCommand": "/app/entrypoint.sh "}' + +From Ruby (e.g. an obacloud job), this mirrors the existing pattern: + + encoded = Base64.strict_encode64(config.to_json) + start_command = "/app/entrypoint.sh #{encoded}" + +Validator flags go after the token (`/app/entrypoint.sh --json`). The +job's exit status is the validator's exit code (`0` no failures, `1` โ‰ฅ1 failure, +`2` config/usage error), so a failed validation shows as a failed run. + +See `docs/superpowers/specs/2026-05-24-oba-validator-design.md` for the validator +design and `docs/superpowers/specs/2026-05-25-render-deployment-design.md` for the +deployment design. diff --git a/docs/superpowers/specs/2026-05-25-render-deployment-design.md b/docs/superpowers/specs/2026-05-25-render-deployment-design.md new file mode 100644 index 0000000..6f9c93e --- /dev/null +++ b/docs/superpowers/specs/2026-05-25-render-deployment-design.md @@ -0,0 +1,158 @@ +# Render Deployment โ€” Design + +**Date:** 2026-05-25 +**Status:** Approved (chat); invocation model corrected after PR review against +obacloud's production Render integration. + +## Purpose + +Add the artifacts needed to deploy `oba-validator` to [Render](https://render.com) +as a **Docker-runtime cron job** whose schedule is set so far in the future that +it effectively never fires on its own. Real validations are launched on demand as +**one-off jobs** against that service, with the entire config โ€” including the +per-server `apiKey` โ€” passed (base64-encoded) in the job's start command. + +This mirrors the deployment approach of the sibling `gtfs-merge-service` repo +(multi-stage Dockerfile) and obacloud's existing Render one-off-job pattern +(`/app/entrypoint.sh `), adapted to this pure-Go tool. + +## Key constraint: keys are per-server + +OneBusAway `apiKey` values differ from one OBA server to the next. Therefore **no +key is baked into the image, the Blueprint, or a service-level env var.** The +whole config (with `apiKey`) is supplied โ€” base64-encoded โ€” at invocation time. +The image and `render.yaml` are key-agnostic. + +## Invocation model + +Render cron jobs and one-off jobs both run the service's container. We exploit +two facts: + +1. A cron job needs a schedule. We use `0 2 29 2 *` (Feb 29 02:00 UTC), so the + automatic run happens at most once every ~4 years โ€” i.e. "virtually never." +2. A **one-off job** runs against an existing service with a caller-supplied + `startCommand`, inheriting the latest build. This is how real validations are + triggered. + +**How Render runs the start command (verified against obacloud's production +integration, `app/jobs/api_key_service_job.rb` and `apply_merge_transform_rule_set_job.rb`):** +Render **splits the start command on whitespace and passes it as argv โ€” there is +no shell** (quotes are not stripped), and the **first token is the executable**. +The image `ENTRYPOINT` is therefore *overridden* by a one-off job's start command, +not appended to. So the start command must name an executable first, and any +argument with spaces or shell-special characters must be encoded into a single +token. + +The validator's config is JSON โ€” full of spaces and special characters โ€” so it +cannot be passed inline. We mirror obacloud's established pattern: **base64-encode +the config** and ship a small `entrypoint.sh` that decodes it. The one-off start +command is: + +``` +/app/entrypoint.sh +``` + +`entrypoint.sh` base64-decodes its argument and execs `oba-validator` with the +resulting JSON as the sole positional argument (`config.Load` accepts a raw JSON +string). As a convenience, an argument that already starts with `{` is treated as +raw JSON and passed through unchanged โ€” base64 never starts with `{`, so the two +cases are unambiguous and local `docker run` stays ergonomic. The `apiKey` rides +inside the encoded config; nothing key-related is in the image. + +## Artifacts + +All new, except edits to `README.md` and `Makefile`. + +### `Dockerfile` (multi-stage, pure Go) + +- **Builder:** `golang:1-alpine`. Copy `go.mod`/`go.sum`, `go mod download`, copy + source, then `CGO_ENABLED=0 GOOS=linux go build -o oba-validator ./cmd/oba-validator`. +- **Runtime:** `alpine:3` + `ca-certificates` (required for HTTPS to the OBA API + and the GTFS / GTFS-realtime feed URLs). Alpine over distroless so `/bin/sh` and + busybox `base64` are available for the entrypoint and for debugging one-off runs. +- Copies in `entrypoint.sh` and sets `ENTRYPOINT ["/app/entrypoint.sh"]`, + `CMD []`. The `ENTRYPOINT` governs scheduled and local runs; a one-off job + overrides it but names `entrypoint.sh` as its first token by convention. + +### `entrypoint.sh` + +A POSIX `sh` script that base64-decodes its first argument into the config JSON +and `exec`s `/app/oba-validator` with it (forwarding any trailing flags before the +config). An argument starting with `{` is passed through as raw JSON. `set -eu` +makes a bad/empty argument fail loudly (no silent fallthrough). Required because +Render passes the start command as whitespace-split argv with no shell. + +### `render.yaml` (Blueprint) + +```yaml +services: + - type: cron + name: oba-validator + runtime: docker + dockerfilePath: ./Dockerfile + schedule: "0 2 29 2 *" # Feb 29 02:00 UTC โ€” effectively never + plan: starter # cron jobs are not free-tier; adjust as needed +``` + +No `envVars` (per the per-server-key constraint). No `dockerCommand`: the rare +scheduled fire runs `entrypoint.sh` with no args, which prints usage and exits 2 โ€” +harmless, at most once every ~4 years. + +### `.dockerignore` + +Exclude build/dev artifacts that don't belong in the build context: +`bin/`, `.git/`, `docs/`, `.github/`, `*.md`, `example_configs/`. (Configs are +not baked in; the base64-encoded config is the input path.) + +### `Makefile` + +Add a `docker-build` target: `docker build -t oba-validator .`. + +### `README.md` + +Add a **Deploying to Render** section covering: local image build, deploying via +the Blueprint, base64-encoding the config, the `POST /v1/services//jobs` curl +with `"startCommand": "/app/entrypoint.sh "`, the equivalent Ruby +(`Base64.strict_encode64`) for obacloud, and the exit-code meaning. + +### Dropped from the merge-service pattern + +- **`env.example`** โ€” not created. There is no env var to set (the key lives in + the config JSON). + +## Filesystem / cache + +The static-GTFS cache defaults to `os.UserCacheDir()/oba-validator`, falling back +to `os.TempDir()` (`/tmp`) when `$HOME` is unset; `MkdirAll` creates it. In the +container this just works and is ephemeral per run. **No persistent disk is +required** โ€” a fresh cache each run is correct for an on-demand validator. + +## Exit codes (already implemented; documented here) + +`0` = no failures ยท `1` = โ‰ฅ1 failure ยท `2` = config/usage error. These surface as +the Render job's exit status, so a failed validation shows as a failed run. + +## Out of scope + +- Baking configs into the image, persistent disk/caching across runs, scheduled + recurring validation of a fixed server, and any name-based key/agency + inference. + +## Verification + +All exercised against the live KCM/Puget Sound config (public sample key +`org.onebusaway.iphone`): + +- `make docker-build` builds clean. +- **Render path:** `docker run --rm oba-validator "$(printf '%s' '' | base64)"` + produces a full report โ€” exercising base64 decode + the validator, and + confirming CA certs / outbound HTTPS work. Exit code propagates through the + `exec` (validation failure โ†’ 1). +- **Render-override simulation:** the same with `--entrypoint /app/entrypoint.sh` + (Render names `entrypoint.sh` as the executable) produces the same report. +- **Local ergonomics:** `docker run --rm oba-validator ''` (raw, + `{`-prefixed) is passed through and produces a report. +- **Flags:** `... --json` emits JSON. +- **Failure modes:** no argument โ†’ exit 2 (usage); a non-base64, non-`{` + argument โ†’ loud `base64` decode error and non-zero exit (no silent + fallthrough). diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..0701803 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Render runs a one-off job's startCommand by splitting it on whitespace and +# passing the result as argv โ€” there is no shell, so quotes are not stripped and +# a JSON config cannot be passed inline (it has spaces and special characters). +# Callers therefore base64-encode the compact config JSON into a single token: +# +# startCommand: /app/entrypoint.sh [validator-flags...] +# +# This script decodes that token and hands the raw JSON to the validator as its +# sole positional argument. A raw JSON argument (one starting with "{") is passed +# through unchanged, which keeps local `docker run` ergonomic; base64 strings +# never start with "{", so the two cases are unambiguous. Any extra arguments are +# forwarded to the validator as flags (which must precede the config). +set -eu + +if [ "$#" -eq 0 ]; then + echo "usage: entrypoint.sh [flags...]" >&2 + exit 2 +fi + +arg="$1" +shift +case "$arg" in + '{'*) config="$arg" ;; + *) config="$(printf '%s' "$arg" | base64 -d)" ;; +esac + +exec /app/oba-validator "$@" "$config" diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..83a9931 --- /dev/null +++ b/render.yaml @@ -0,0 +1,14 @@ +# Render Blueprint: deploy oba-validator as a Docker cron job that effectively +# never runs on its own. Trigger real validations as one-off jobs against this +# service, passing the whole config (including apiKey) as a base64-encoded start +# command: `/app/entrypoint.sh `. See "Deploying to Render" in the +# README. +services: + - type: cron + name: oba-validator + runtime: docker + dockerfilePath: ./Dockerfile + # Feb 29 02:00 UTC โ€” fires at most once every ~4 years, i.e. virtually never. + schedule: "0 2 29 2 *" + # Cron jobs are not available on the free tier; adjust to taste. + plan: starter From cfc138c70332fdbbd78c7e28ef425cc5e3989700 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 25 May 2026 10:28:26 -0700 Subject: [PATCH 2/3] ci: add GitHub Actions for tests and Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel jobs on push-to-main and pull_request: - test: gofmt check, go vet, `go mod tidy` cleanliness, `go mod verify`, `go test -race ./...` (the validator fans out concurrently, so the race detector is worth running), and shellcheck on entrypoint.sh. - docker: build the deployment image and smoke-test it offline โ€” base64 of "{}" exercises entrypoint.sh -> oba-validator -> config.Load and must exit 2. The live integration test (OBA_VALIDATOR_LIVE) is intentionally not run in CI; it depends on a real OBA server. Includes concurrency cancellation and per-job timeouts. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9939b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +# Cancel superseded runs on the same branch/PR to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test & lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean (run 'make fmt'):" >&2 + echo "$unformatted" >&2 + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Check go.mod/go.sum are tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum + + - name: go mod verify + run: go mod verify + + - name: Test with race detector + run: go test -race ./... + + - name: Shellcheck entrypoint.sh + run: shellcheck entrypoint.sh + + docker: + name: Docker image + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: make docker-build + + - name: Smoke test the entrypoint + run: | + # base64 of "{}" decodes to an (incomplete) JSON config, so the chain + # entrypoint.sh -> oba-validator -> config.Load runs entirely offline and + # must exit 2 (config error). This proves the image is wired correctly + # without depending on a live OBA server or feeds. + token=$(printf '%s' '{}' | base64) + set +e + output=$(docker run --rm oba-validator "$token" 2>&1) + code=$? + set -e + echo "$output" + if [ "$code" -ne 2 ]; then + echo "expected exit 2 (config error), got $code" >&2 + exit 1 + fi From e31fbbb938f0a7c233aa12ce315483acbd7aa24d Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 25 May 2026 10:30:05 -0700 Subject: [PATCH 3/3] ci: bump checkout to v6 and setup-go to v6 (Node 24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/checkout@v4 and actions/setup-go@v5 run on Node.js 20, which GitHub forces off by June 2, 2026. The v6 majors run on Node 24. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9939b6..a002685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build image run: make docker-build