Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
bin/
.git/
.github/
docs/
example_configs/
*.md
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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 <base64-config>`. (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 []
Comment on lines +1 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add a non-root user for runtime security.

The container runs as root, which increases the attack surface if the validator processes untrusted input or has vulnerabilities. Since one-off jobs accept base64-encoded config from external callers, running as a non-root user follows the principle of least privilege.

🔒 Proposed fix to add a non-root user
 # 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
 
+# Run as non-root user
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
 WORKDIR /app
 COPY --from=builder /build/oba-validator /app/oba-validator
 COPY entrypoint.sh /app/entrypoint.sh
 RUN chmod +x /app/entrypoint.sh
 
+USER appuser
+
 # 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 <base64-config>`. (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 []
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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 <base64-config>`. (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 []
# 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
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /build/oba-validator /app/oba-validator
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
USER appuser
# 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 <base64-config>`. (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 []
🧰 Tools
🪛 Trivy (0.69.3)

[error] 1-1: Image user should not be 'root'

Specify at least 1 USER command in Dockerfile with non-root user as argument

Rule: DS-0002

Learn more

(IaC/Dockerfile)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 1 - 34, Add a non-root runtime user and switch to it
in the Dockerfile: create a user/group (e.g., obauser), chown /app and the
binary (/app/oba-validator and /app/entrypoint.sh) to that user, ensure
entrypoint.sh remains executable, and add USER obauser before ENTRYPOINT so the
container does not run as root; reference the Dockerfile stages that COPY
--from=builder /build/oba-validator and COPY entrypoint.sh /app/entrypoint.sh
and ensure permissions/ownership changes happen after those COPY steps.

7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
61 changes: 56 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base64-of-compact-config-json>

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/<service-id>/jobs' \
--header 'Authorization: Bearer <render-api-key>' \
--header 'Content-Type: application/json' \
--data-raw '{"startCommand": "/app/entrypoint.sh <base64-config>"}'

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 <base64> --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.
158 changes: 158 additions & 0 deletions docs/superpowers/specs/2026-05-25-render-deployment-design.md
Original file line number Diff line number Diff line change
@@ -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 <base64>`), 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 <base64-of-compact-config-json>
```

`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/<id>/jobs` curl
with `"startCommand": "/app/entrypoint.sh <base64>"`, 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' '<compact-json>' | 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 '<compact-json>'` (raw,
`{`-prefixed) is passed through and produces a report.
- **Flags:** `... <base64> --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).
28 changes: 28 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 <base64-config-json> [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 <base64-config-json | raw-json> [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"
14 changes: 14 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
@@ -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 <base64-config>`. 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