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
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
79 changes: 79 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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@v6

- uses: actions/setup-go@v6
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@v6

- 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
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).
Loading