From db58f26d8e00aaaea97f97fbcc12c624f79068b3 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Sat, 23 May 2026 18:29:58 -0700 Subject: [PATCH 1/7] feat: render one GtfsRealtimeSource bean per feed via FEEDS array --- ...ata-federation-webapp-data-sources.xml.hbs | 68 +++++++-------- oba/config/template_renderer/main_test.go | 83 +++++++++++++++++++ 2 files changed, 118 insertions(+), 33 deletions(-) diff --git a/oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs b/oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs index ce9dadf..0ca4708 100644 --- a/oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs +++ b/oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs @@ -46,44 +46,46 @@ - {{#if GTFS_RT_AVAILABLE}} - - {{#if TRIP_UPDATES_URL}} - - {{/if}} + {{#if FEEDS.length}} + {{#each FEEDS}} + + {{#if this.tripUpdatesUrl}} + + {{/if}} - {{#if VEHICLE_POSITIONS_URL}} - - {{/if}} + {{#if this.vehiclePositionsUrl}} + + {{/if}} - {{#if ALERTS_URL}} - - {{/if}} + {{#if this.alertsUrl}} + + {{/if}} - {{#if REFRESH_INTERVAL}} - - {{/if}} + {{#if this.refreshInterval}} + + {{/if}} - {{#if AGENCY_ID_LIST.length}} - - - {{#each AGENCY_ID_LIST}} - {{ this }} - {{/each}} - - - {{else if AGENCY_ID}} - - {{/if}} + {{#if this.agencyIds.length}} + + + {{#each this.agencyIds}} + {{ this }} + {{/each}} + + + {{else if this.agencyId}} + + {{/if}} - {{#if HAS_API_KEY}} - - - - - - {{/if}} - + {{#if this.feedApiKey}} + + + + + + {{/if}} + + {{/each}} {{/if}} diff --git a/oba/config/template_renderer/main_test.go b/oba/config/template_renderer/main_test.go index 7d3f400..ceba2dc 100644 --- a/oba/config/template_renderer/main_test.go +++ b/oba/config/template_renderer/main_test.go @@ -134,3 +134,86 @@ func TestRenderTemplateErrors(t *testing.T) { t.Error("Expected an error with invalid JSON, but got none") } } + +const federationTemplatePath = "../onebusaway-transit-data-federation-webapp-data-sources.xml.hbs" + +func TestFederationTemplateMultipleFeeds(t *testing.T) { + json := `{"FEEDS":[` + + `{"tripUpdatesUrl":"https://a/trips","agencyIds":["unitrans"],"feedApiKey":"x-key","feedApiValue":"secret"},` + + `{"vehiclePositionsUrl":"https://b/vehicles","agencyIds":["kcm"]}` + + `]}` + + out, err := renderTemplate(federationTemplatePath, json) + if err != nil { + t.Fatalf("renderTemplate returned an error: %v", err) + } + if c := strings.Count(out, "GtfsRealtimeSource"); c != 2 { + t.Errorf("expected 2 GtfsRealtimeSource beans, got %d\n%s", c, out) + } + if !strings.Contains(out, `value="https://a/trips"`) { + t.Errorf("missing first feed tripUpdatesUrl:\n%s", out) + } + if !strings.Contains(out, `value="https://b/vehicles"`) { + t.Errorf("missing second feed vehiclePositionsUrl:\n%s", out) + } + if !strings.Contains(out, `unitrans`) { + t.Errorf("missing agencyId for first feed:\n%s", out) + } + if !strings.Contains(out, `kcm`) { + t.Errorf("missing agencyId for second feed:\n%s", out) + } + if !strings.Contains(out, ` Date: Sat, 23 May 2026 18:36:08 -0700 Subject: [PATCH 2/7] feat: bootstrap builds FEEDS array (GTFS_RT_FEEDS or legacy fallback) --- oba/bootstrap.sh | 69 +++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/oba/bootstrap.sh b/oba/bootstrap.sh index 545ed66..7a07060 100755 --- a/oba/bootstrap.sh +++ b/oba/bootstrap.sh @@ -31,45 +31,42 @@ hbs_renderer -input "$API_XML_SOURCE" \ FEDERATION_XML_SOURCE="/oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs" FEDERATION_XML_DESTINATION="$CATALINA_HOME/webapps/onebusaway-transit-data-federation-webapp/WEB-INF/classes/data-sources.xml" -if [ -z "$TRIP_UPDATES_URL" ] && [ -z "$VEHICLE_POSITIONS_URL" ]; then - GTFS_RT_AVAILABLE="" - echo "No GTFS-RT related environment variables are set. Removing element from data-sources.xml" -else - GTFS_RT_AVAILABLE="1" - echo "GTFS-RT related environment variables are set. Setting them in data-sources.xml" -fi - -# Check if the GTFS_RT authentication header is set -if [ -n "$FEED_API_KEY" ] && [ -n "$FEED_API_VALUE" ]; then - HAS_API_KEY="1" -else - HAS_API_KEY="" -fi - -# Handle AGENCY_ID_LIST properly - if it's a JSON array, use it directly, otherwise treat as empty -if [ -n "$AGENCY_ID_LIST" ]; then - # Remove the outer quotes if they exist and use the array directly - AGENCY_ID_LIST_JSON="$AGENCY_ID_LIST" +# Build the FEEDS array for the transit-data-federation data-sources.xml. +# Prefer the multi-feed GTFS_RT_FEEDS env var; fall back to the legacy +# single-feed vars so already-deployed Dockerfiles keep working. +# Strip whitespace for the guard only, so a blank/whitespace GTFS_RT_FEEDS +# falls through to the legacy/no-feeds path instead of producing invalid JSON. +GTFS_RT_FEEDS_TRIMMED="$(printf '%s' "$GTFS_RT_FEEDS" | tr -d '[:space:]')" +if [ -n "$GTFS_RT_FEEDS_TRIMMED" ] && [ "$GTFS_RT_FEEDS_TRIMMED" != "[]" ]; then + echo "GTFS_RT_FEEDS is set. Rendering multiple GTFS-RT feeds." + FEEDS_JSON="$GTFS_RT_FEEDS" +elif [ -n "$TRIP_UPDATES_URL" ] || [ -n "$VEHICLE_POSITIONS_URL" ]; then + echo "Legacy single-feed GTFS-RT env vars are set. Normalizing into one feed." + if [ -n "$AGENCY_ID_LIST" ]; then + AGENCY_IDS_JSON="$AGENCY_ID_LIST" + elif [ -n "$AGENCY_ID" ]; then + AGENCY_IDS_JSON="[\"$AGENCY_ID\"]" + else + AGENCY_IDS_JSON="[]" + fi + FEEDS_JSON=$(cat < Date: Sat, 23 May 2026 18:42:41 -0700 Subject: [PATCH 3/7] ci: run template_renderer go tests --- .github/workflows/test.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8f2fd37..a11bb61 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -7,6 +7,22 @@ on: pull_request: jobs: + renderer: + name: Template renderer tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Run renderer tests + working-directory: oba/config/template_renderer + run: go test ./... + image: name: Build Docker Image runs-on: ubuntu-latest From eb5ee5f2edbb5ad78dc44ee5b7c957eba5344423 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Sat, 23 May 2026 18:42:45 -0700 Subject: [PATCH 4/7] docs: document GTFS_RT_FEEDS multi-feed env var --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 539c6b1..5f89bfd 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ You can find the latest published Docker images on Docker Hub: * `GTFS_URL` - The URL to the GTFS feed you want to use. * GTFS-RT Support (Optional) * `TZ` - The timezone for the server. Ensure that the server's timezone matches the timezone specified in your static GTFS `agency.txt` file. The timezone format is the IANA standard, and [a full list of timezones can be found on Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List). + * `GTFS_RT_FEEDS` - Preferred for configuring one OR many GTFS-RT feeds. A JSON array of feed objects; each object may contain `tripUpdatesUrl`, `vehiclePositionsUrl`, `alertsUrl`, `refreshInterval`, `agencyIds` (array), `feedApiKey`, and `feedApiValue`. Example: `[{"tripUpdatesUrl":"https://a/trips","agencyIds":["unitrans"]},{"vehiclePositionsUrl":"https://b/veh","agencyIds":["kcm"]}]`. When set, it takes precedence over the single-feed variables below. * `ALERTS_URL` - Service Alerts URL for GTFS-RT. * `TRIP_UPDATES_URL` - Trip Updates URL for GTFS-RT. * `VEHICLE_POSITIONS_URL` - Vehicle Positions URL for GTFS-RT. From 478592e3438e6c9dfdbb0493147a2b5e7ed03cec Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Sat, 23 May 2026 20:05:51 -0700 Subject: [PATCH 5/7] docs: add plan files for multiple gtfs-rt feeds --- .../2026-05-23-multi-gtfs-rt-feeds-docker.md | 331 +++++++++++++ .../2026-05-23-multi-gtfs-rt-feeds-design.md | 447 ++++++++++++++++++ 2 files changed, 778 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-multi-gtfs-rt-feeds-docker.md create mode 100644 docs/superpowers/specs/2026-05-23-multi-gtfs-rt-feeds-design.md diff --git a/docs/superpowers/plans/2026-05-23-multi-gtfs-rt-feeds-docker.md b/docs/superpowers/plans/2026-05-23-multi-gtfs-rt-feeds-docker.md new file mode 100644 index 0000000..f172826 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-multi-gtfs-rt-feeds-docker.md @@ -0,0 +1,331 @@ +# Multiple GTFS-RT Feeds — Docker Image Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Teach the `onebusaway/docker` image to render one `GtfsRealtimeSource` Spring bean per feed from a new `GTFS_RT_FEEDS` JSON env var, while keeping the legacy single-feed env vars working. + +**Architecture:** A new `GTFS_RT_FEEDS` env var carries a JSON array of feed objects. `bootstrap.sh` prefers it; when it's unset it normalizes the legacy single-feed vars (`TRIP_UPDATES_URL`, etc.) into a one-element array. Either way it hands `{ "FEEDS": [...] }` to the Go/raymond Handlebars renderer, and the data-sources template iterates with `{{#each FEEDS}}` to emit one bean per feed. + +**Tech Stack:** Bash, Handlebars (Go `github.com/mailgun/raymond/v2`), Go test, GitHub Actions. + +> **⚠️ This plan operates in a DIFFERENT repository:** `/Users/aaron/repos/onebusaway/docker` (not the obacloud repo this plan file lives in). All paths below are relative to that repo. `cd /Users/aaron/repos/onebusaway/docker` before starting, and make all commits there. + +> **⚠️ Ship order:** This plan must merge AND be released (so `2.7.1-latest` is re-published with these changes) BEFORE the OBACloud plan ships. See the spec's "Backward compatibility & ship sequencing" section. The legacy-normalization path is exercised by every existing deployment on its next rebuild, which is why it has its own test below. + +**Reference spec:** `docs/superpowers/specs/2026-05-23-multi-gtfs-rt-feeds-design.md` (in the obacloud repo). + +--- + +## File Structure + +- **Modify** `oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs` — wrap the single `GtfsRealtimeSource` bean in `{{#each FEEDS}}`. +- **Modify** `oba/config/template_renderer/main_test.go` — add tests that render the real federation template against `FEEDS` JSON. +- **Modify** `oba/bootstrap.sh` — build the `FEEDS` array (prefer `GTFS_RT_FEEDS`, else normalize legacy vars). +- **Modify** `.github/workflows/test.yaml` — add a `go test` step so the renderer tests run in CI. +- **Modify** `README.md` — document `GTFS_RT_FEEDS`. + +--- + +## Task 1: Multi-feed Handlebars template (TDD via Go test) + +**Files:** +- Test: `oba/config/template_renderer/main_test.go` +- Modify: `oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs` + +- [ ] **Step 1: Write failing Go tests that render the real federation template** + +Append to `oba/config/template_renderer/main_test.go` (the package is `main`, so `renderTemplate` is directly callable; the template lives one directory up): + +```go +func TestFederationTemplateMultipleFeeds(t *testing.T) { + tmpl := "../onebusaway-transit-data-federation-webapp-data-sources.xml.hbs" + json := `{"FEEDS":[` + + `{"tripUpdatesUrl":"https://a/trips","agencyIds":["unitrans"],"feedApiKey":"x-key","feedApiValue":"secret"},` + + `{"vehiclePositionsUrl":"https://b/vehicles","agencyIds":["kcm"]}` + + `]}` + + out, err := renderTemplate(tmpl, json) + if err != nil { + t.Fatalf("renderTemplate returned an error: %v", err) + } + if c := strings.Count(out, "GtfsRealtimeSource"); c != 2 { + t.Errorf("expected 2 GtfsRealtimeSource beans, got %d\n%s", c, out) + } + if !strings.Contains(out, `value="https://a/trips"`) { + t.Errorf("missing first feed tripUpdatesUrl:\n%s", out) + } + if !strings.Contains(out, `value="https://b/vehicles"`) { + t.Errorf("missing second feed vehiclePositionsUrl:\n%s", out) + } + if !strings.Contains(out, `unitrans`) || !strings.Contains(out, `kcm`) { + t.Errorf("missing per-feed agencyIds:\n%s", out) + } + if !strings.Contains(out, `` comment through its closing `{{/if}}` (currently lines 48–87) with: + +```hbs + + {{#if FEEDS.length}} + {{#each FEEDS}} + + {{#if this.tripUpdatesUrl}} + + {{/if}} + + {{#if this.vehiclePositionsUrl}} + + {{/if}} + + {{#if this.alertsUrl}} + + {{/if}} + + {{#if this.refreshInterval}} + + {{/if}} + + {{#if this.agencyIds.length}} + + + {{#each this.agencyIds}} + {{ this }} + {{/each}} + + + {{else if this.agencyId}} + + {{/if}} + + {{#if this.feedApiKey}} + + + + + + {{/if}} + + {{/each}} + {{/if}} +``` + +(Leave the surrounding `` and the other static beans untouched.) + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `cd /Users/aaron/repos/onebusaway/docker/oba/config/template_renderer && go test ./...` +Expected: PASS (all three new tests plus the existing ones). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/aaron/repos/onebusaway/docker +git add oba/config/onebusaway-transit-data-federation-webapp-data-sources.xml.hbs oba/config/template_renderer/main_test.go +git commit -m "feat: render one GtfsRealtimeSource bean per feed via FEEDS array" +``` + +--- + +## Task 2: bootstrap.sh builds the FEEDS array + +**Files:** +- Modify: `oba/bootstrap.sh` + +- [ ] **Step 1: Replace the single-feed JSON_CONFIG block** + +In `oba/bootstrap.sh`, replace everything from the comment `# Check if the GTFS_RT authentication header is set` through the `hbs_renderer ... "$FEDERATION_XML_DESTINATION"` invocation for the federation webapp (currently lines ~42–76, i.e. the `HAS_API_KEY` / `AGENCY_ID_LIST_JSON` / `JSON_CONFIG` logic and the federation render call) with: + +```bash +# Build the FEEDS array for the transit-data-federation data-sources.xml. +# Prefer the multi-feed GTFS_RT_FEEDS env var; fall back to the legacy +# single-feed vars so already-deployed Dockerfiles keep working. +if [ -n "$GTFS_RT_FEEDS" ] && [ "$GTFS_RT_FEEDS" != "[]" ]; then + FEEDS_JSON="$GTFS_RT_FEEDS" + echo "GTFS_RT_FEEDS is set. Rendering multiple GTFS-RT feeds." +elif [ -n "$TRIP_UPDATES_URL" ] || [ -n "$VEHICLE_POSITIONS_URL" ]; then + echo "Legacy single-feed GTFS-RT env vars are set. Normalizing into one feed." + if [ -n "$AGENCY_ID_LIST" ]; then + AGENCY_IDS_JSON="$AGENCY_ID_LIST" + elif [ -n "$AGENCY_ID" ]; then + AGENCY_IDS_JSON="[\"$AGENCY_ID\"]" + else + AGENCY_IDS_JSON="[]" + fi + FEEDS_JSON=$(cat <unitrans`, and **no** `headersMap`. + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron/repos/onebusaway/docker +git add oba/bootstrap.sh +git commit -m "feat: bootstrap builds FEEDS array (GTFS_RT_FEEDS or legacy fallback)" +``` + +--- + +## Task 3: Run the renderer tests in CI + +**Files:** +- Modify: `.github/workflows/test.yaml` + +- [ ] **Step 1: Add a Go test job** + +In `.github/workflows/test.yaml`, add this job under `jobs:` (sibling to `image:`): + +```yaml + renderer: + name: Template renderer tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Run renderer tests + working-directory: oba/config/template_renderer + run: go test ./... +``` + +- [ ] **Step 2: Verify the command the CI step runs passes locally** + +Run: `cd /Users/aaron/repos/onebusaway/docker/oba/config/template_renderer && go test ./...` +Expected: PASS (this is the exact command CI will run). + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron/repos/onebusaway/docker +git add .github/workflows/test.yaml +git commit -m "ci: run template_renderer go tests" +``` + +--- + +## Task 4: Document GTFS_RT_FEEDS in the README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add the multi-feed env var to the GTFS-RT section** + +In `README.md`, in the `* GTFS-RT Support (Optional)` list (currently lines ~101–113), add a new bullet immediately under the `* TZ ...` line: + +```markdown + * `GTFS_RT_FEEDS` - Preferred for configuring one OR many GTFS-RT feeds. A JSON array of feed objects; each object may contain `tripUpdatesUrl`, `vehiclePositionsUrl`, `alertsUrl`, `refreshInterval`, `agencyIds` (array), `feedApiKey`, and `feedApiValue`. Example: `[{"tripUpdatesUrl":"https://a/trips","agencyIds":["unitrans"]},{"vehiclePositionsUrl":"https://b/veh","agencyIds":["kcm"]}]`. When set, it takes precedence over the single-feed variables below. + * The following single-feed variables remain supported (and are normalized into one feed when `GTFS_RT_FEEDS` is not set): +``` + +(The existing `ALERTS_URL` / `TRIP_UPDATES_URL` / etc. bullets stay as the nested "single-feed variables" list.) + +- [ ] **Step 2: Commit** + +```bash +cd /Users/aaron/repos/onebusaway/docker +git add README.md +git commit -m "docs: document GTFS_RT_FEEDS multi-feed env var" +``` + +--- + +## Self-Review (completed during planning) + +- **Spec coverage:** template loop (Task 1), bootstrap legacy-compat (Task 2), Go test + CI gap I1 (Tasks 1 & 3), README (Task 4). The compose e2e extension is optional in the spec and omitted here (the Go tests cover render correctness; the existing e2e covers startup). +- **Placeholder scan:** none — every step has complete code/commands. +- **Consistency:** the JSON key names (`tripUpdatesUrl`, `agencyIds`, `feedApiKey`, …) match the spec contract and the OBACloud generator output in the sibling plan. +- **raymond support:** `{{#each}}`, `{{this.prop}}`, `{{#if x.length}}`, `{{else if}}` confirmed (spec §template, verified by running raymond v2.0.48). diff --git a/docs/superpowers/specs/2026-05-23-multi-gtfs-rt-feeds-design.md b/docs/superpowers/specs/2026-05-23-multi-gtfs-rt-feeds-design.md new file mode 100644 index 0000000..8611686 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-multi-gtfs-rt-feeds-design.md @@ -0,0 +1,447 @@ +# Multiple GTFS-RT Feeds in the Dockerfile Config — Design + +## Goal + +The "Edit Dockerfile Config" modal currently supports exactly one set of GTFS-RT feed URLs (trip updates, vehicle positions, alerts) plus one refresh interval, agency-ID list, and API-key header. OneBusAway itself supports many realtime feeds — each is a separate `GtfsRealtimeSource` Spring bean — so the limitation is purely in OBACloud's UI/generator and in the docker image's bootstrap/template, not in OneBusAway. + +This work lets an organization configure **N** GTFS-RT feed sets. It spans two repos: + +1. **`onebusaway/docker`** (`/Users/aaron/repos/onebusaway/docker`) — teach the image to render one `GtfsRealtimeSource` bean per feed, driven by a new `GTFS_RT_FEEDS` env var, **while keeping the existing single-feed env vars working** (additive, non-breaking). +2. **`obacloud`** — replace `DockerfileConfig`'s flat single-feed fields with a `feeds` array, add an accordion editor to manage feeds, emit the `GTFS_RT_FEEDS` env var, and update the **service wizard** (which also writes the flat fields today) to produce a single `feeds[0]` during onboarding. + +## Decisions + +| Question | Decision | +|---|---| +| Does the docker image support multiple feeds today? | **No.** `bootstrap.sh` reads single env vars and the Handlebars template renders exactly one ``. OneBusAway *can* hold many `GtfsRealtimeSource` beans, so the fix is in the image's bootstrap + template, not in OneBusAway. | +| Cross-repo contract | **Single JSON env var `GTFS_RT_FEEDS`** carrying an array of feed objects. Matches the existing `AGENCY_ID_LIST`-as-JSON convention; the template iterates with `{{#each FEEDS}}`; no per-field shell loops. | +| Docker backward compatibility | **Additive.** The legacy single-feed env vars (`TRIP_UPDATES_URL`, `VEHICLE_POSITIONS_URL`, `ALERTS_URL`, `REFRESH_INTERVAL`, `AGENCY_ID`, `AGENCY_ID_LIST`, `FEED_API_KEY`, `FEED_API_VALUE`) keep working. When `GTFS_RT_FEEDS` is unset, `bootstrap.sh` normalizes the legacy vars into a one-element `FEEDS` array, so the template has a single code path and every already-deployed Dockerfile keeps building untouched. | +| OBACloud data model | **Clean `feeds` array.** Replace the flat single-feed attributes on `DockerfileConfig` with `attribute :feeds, GtfsRtFeed.to_array_type`. A one-time backfill migration copies any existing flat values into `feeds[0]` so nothing is lost. | +| Agency ID format | **Unchanged.** The per-feed "Agency ID List" field still takes a JSON array string (e.g. `["unitrans"]`), exactly like today. The generator parses it into the `agencyIds` array inside the feed object. No UX change. | +| Editor UI | **Accordion.** Each feed is a collapsible panel (`CollapsibleCardComponent` / `disclosure` controller) summarized by its label (or "Feed N"); "Add feed" / "Remove" via a Stimulus controller modeled on the existing `rt_feeds_controller.js`. | +| Feed label | **OBACloud-only metadata.** Optional; shown in the accordion header and stored on the feed, but **not** emitted to `GTFS_RT_FEEDS` (the image has no use for it). | +| Existing production usage | **Greenfield / negligible.** The JSONB column landed ~6 weeks ago; the backfill migration is a safety net rather than a migration of meaningful volume. | +| Service wizard | **In scope.** `ServiceWizards::SaveConfigStep` and the wizard's `config_params` also write the flat feed fields; both are updated to build a single `feeds[0]`. Onboarding stays single-feed in the UI. | +| Feed-level validation | **`validates :feeds, store_model: { merge_errors: true }`** on `DockerfileConfig`, so child feed errors actually surface — without it the single-quote safety never runs. | +| Refresh interval | **Validated numeric** per feed (`numericality: integer > 0`), closing a pre-existing gap where `"abc"` would render invalid Spring XML. | + +## The contract: `GTFS_RT_FEEDS` + +A single Dockerfile line, single-quoted to match the existing `AGENCY_ID_LIST` convention: + +```dockerfile +ENV GTFS_RT_FEEDS='[{"tripUpdatesUrl":"https://a/trips","vehiclePositionsUrl":"https://a/vehicles","alertsUrl":"https://a/alerts","refreshInterval":"30","agencyIds":["unitrans"],"feedApiKey":"x-api-key","feedApiValue":"secret"},{"tripUpdatesUrl":"https://b/trips","agencyIds":["kcm"]}]' +``` + +Per-feed object keys (all optional; a feed needs at least one URL to be useful): + +| JSON key | Source field | Bean property | +|---|---|---| +| `tripUpdatesUrl` | `trip_updates_url` | `tripUpdatesUrl` | +| `vehiclePositionsUrl` | `vehicle_positions_url` | `vehiclePositionsUrl` | +| `alertsUrl` | `alerts_url` | `alertsUrl` | +| `refreshInterval` | `refresh_interval` | `refreshInterval` | +| `agencyIds` | `agency_id_list` (JSON array string, parsed) | `agencyIds` → `` | +| `feedApiKey` | `feed_api_key` | `headersMap` entry key | +| `feedApiValue` | `feed_api_value` | `headersMap` entry value | + +`label` is **not** present in the contract. When zero feeds are configured, the `GTFS_RT_FEEDS` line is omitted entirely and no GTFS-RT beans are rendered (matching today's "GTFS-RT optional" behavior). + +## Architecture + +``` +OBACloud accordion editor + │ (global: base_image, timezone · repeatable feeds) + ▼ +DockerfileConfig.feeds ── GtfsRtFeed StoreModel array, persisted in existing JSONB column + │ + ▼ Dockerfiles::GenerateObaApi +ENV GTFS_RT_FEEDS='[{…},{…}]' ◄── THE CROSS-REPO CONTRACT + │ pushed to OneBusAway/obacloud-dockerfiles → Render builds image + ▼ +bootstrap.sh ── if $GTFS_RT_FEEDS set → use it; else normalize legacy single-feed vars into a 1-element FEEDS array + │ hands { "FEEDS": [...] } to hbs_renderer (raymond) + ▼ +data-sources.xml.hbs ── {{#each FEEDS}} → one per feed + ▼ +OneBusAway runs with N realtime feeds +``` + +The `GTFS_RT_FEEDS` JSON is the contract; everything above it is OBACloud, everything below is the docker image. + +--- + +## Repo 1: Docker image (`onebusaway/docker`) + +### `oba/bootstrap.sh` + +Replace the single-feed `JSON_CONFIG` construction (current lines ~34–76) with: prefer `GTFS_RT_FEEDS`; otherwise normalize the legacy single-feed vars into a one-element array. The template then only sees a `FEEDS` array. + +```bash +# Build the FEEDS array for the transit-data-federation data-sources.xml. +# Prefer the multi-feed GTFS_RT_FEEDS env var; fall back to the legacy +# single-feed vars so already-deployed Dockerfiles keep working. +if [ -n "$GTFS_RT_FEEDS" ] && [ "$GTFS_RT_FEEDS" != "[]" ]; then + FEEDS_JSON="$GTFS_RT_FEEDS" +elif [ -n "$TRIP_UPDATES_URL" ] || [ -n "$VEHICLE_POSITIONS_URL" ]; then + # Normalize legacy single-feed vars into a one-element FEEDS array. + if [ -n "$AGENCY_ID_LIST" ]; then + AGENCY_IDS_JSON="$AGENCY_ID_LIST" + elif [ -n "$AGENCY_ID" ]; then + AGENCY_IDS_JSON="[\"$AGENCY_ID\"]" + else + AGENCY_IDS_JSON="[]" + fi + FEEDS_JSON=$(cat < +{{#if FEEDS.length}} + {{#each FEEDS}} + + {{#if this.tripUpdatesUrl}} + + {{/if}} + {{#if this.vehiclePositionsUrl}} + + {{/if}} + {{#if this.alertsUrl}} + + {{/if}} + {{#if this.refreshInterval}} + + {{/if}} + {{#if this.agencyIds.length}} + + + {{#each this.agencyIds}} + {{ this }} + {{/each}} + + + {{else if this.agencyId}} + + {{/if}} + {{#if this.feedApiKey}} + + + + + + {{/if}} + + {{/each}} +{{/if}} +``` + +The renderer is Go + `github.com/mailgun/raymond/v2` (a full Handlebars implementation). `{{#each}}`, `{{this.prop}}`, `{{#if x.length}}`, nested `{{#each this.agencyIds}}`, and `{{else if}}` are all supported — the existing template already uses `{{#each}}` + `.length`, and the new constructs were verified by running raymond v2 directly (including empty `agencyIds: []` correctly rendering nothing). The `{{else if this.agencyId}}` (singular) branch is **unreachable** for OBACloud-generated configs — both the generator and the legacy normalization only ever emit `agencyIds`. Keep it solely to honor hand-written `GTFS_RT_FEEDS` that use a singular `agencyId`. + +### Testing the render — and a CI gap to close + +Two existing "safety nets" don't currently hold, and the design depends on fixing them: + +- **No `go test` step in CI.** `.github/workflows/test.yaml` only builds images and runs the compose e2e — it never runs `go test`. A renderer test protects nothing until CI runs it. **Add** a `go test ./oba/config/template_renderer/...` step to `test.yaml`. +- **The compose e2e sets no GTFS-RT env.** `bin/validate.sh` / `compose.yaml` configure zero realtime feeds, so the e2e only ever exercises the "no feeds" path and never renders a `GtfsRealtimeSource` bean — neither legacy nor multi-feed. + +**New Go tests** in `oba/config/template_renderer/main_test.go` that render the *actual* federation template: +- A 2-feed `FEEDS` JSON → two `GtfsRealtimeSource` beans with correct per-feed `tripUpdatesUrl`, `agencyIds` (``), and `headersMap`. +- Empty `FEEDS` (`[]`) → zero beans. +- A **legacy-normalized** one-element array (what `bootstrap.sh` builds from `TRIP_UPDATES_URL` et al.) → one correct bean. This path matters disproportionately because of the mutable-tag blast radius below. + +Optionally extend `compose.yaml` + `bin/validate.sh` to set `GTFS_RT_FEEDS` and assert a rendered bean end-to-end. + +### `README.md` + +Document `GTFS_RT_FEEDS` (the JSON shape) and note the legacy single-feed vars remain supported for one feed. + +### Backward compatibility & ship sequencing + +The production base image `opentransitsoftwarefoundation/onebusaway-api-webapp:2.7.1-latest` is published via a **GitHub Release** (`docker.yaml` → `buildx-release`), and `-latest` is a **mutable tag** re-pushed on each `2.7.1-vX.Y.Z` release. + +**Ship order:** +1. Merge the docker change. +2. Cut a docker release so `2.7.1-latest` includes multi-feed support. +3. Ship the OBACloud change. New multi-feed Dockerfiles now build correctly; existing single-feed Dockerfiles continue to build because the legacy env vars are still honored. + +**Blast radius — important.** "Cannot break" holds *at rest*, but `-latest` is mutable. The next time an existing service is rebuilt — `RebuildAppServicesForRuleSetJob` after any GTFS merge (`app/jobs/rebuild_app_services_for_rule_set_job.rb`), or a manual redeploy — it pulls the new image and runs the **new `bootstrap.sh` legacy-normalization branch**. So that branch is exercised by *every existing deployment* on its next rebuild, asynchronously, triggered by routine merges — not just by new configs. The legacy normalization must therefore have automated coverage (see the legacy-path Go test above), not just a visual once-over. + +--- + +## Repo 2: OBACloud + +### New model: `app/models/gtfs_rt_feed.rb` + +A `StoreModel` mirroring the existing `FeedValidationTargetRtFeed` pattern. + +```ruby +class GtfsRtFeed + include StoreModel::Model + + # Everything below lands inside a single-quoted ENV line wrapping JSON, so a + # literal single quote would terminate the wrapper. JSON encoding handles + # double quotes and backslashes; newlines/CRs are rejected for cleanliness. + UNSAFE_CHARACTERS = /[\n\r']/ + + attribute :label, :string + attribute :trip_updates_url, :string + attribute :vehicle_positions_url, :string + attribute :alerts_url, :string + attribute :refresh_interval, :string, default: "30" + attribute :agency_id_list, :string + attribute :feed_api_key, :string + attribute :feed_api_value, :string + + validates :label, :trip_updates_url, :vehicle_positions_url, :alerts_url, + :refresh_interval, :agency_id_list, :feed_api_key, :feed_api_value, + format: { without: UNSAFE_CHARACTERS, message: "contains invalid characters" }, + allow_blank: true + validates :refresh_interval, + numericality: { only_integer: true, greater_than: 0 }, allow_blank: true +end +``` + +### `app/models/dockerfile_config.rb` + +Drop the nine flat feed attributes; keep `base_image`, `timezone`, `revision`. Add the feeds array + nested attributes (exactly how `RegionData` holds `bounds`). + +```ruby +class DockerfileConfig + include StoreModel::Model + + UNSAFE_CHARACTERS = /[\n\r"\\]/ + + attribute :base_image, :string, default: "opentransitsoftwarefoundation/onebusaway-api-webapp:2.7.1-latest" + attribute :timezone, :string, default: "America/Los_Angeles" + attribute :feeds, GtfsRtFeed.to_array_type, default: -> { [] } + attribute :revision, :integer, default: 1 + + # reject_if drops the template row and all-blank added rows. refresh_interval + # defaults to "30", so it must be excluded or it would make every row "present". + # No _destroy: the editor physically removes rows and the full feeds list is + # re-submitted on every save, so the array is replaced wholesale. + accepts_nested_attributes_for :feeds, + reject_if: ->(attrs) { attrs.except("_destroy", "label", "refresh_interval").values.all?(&:blank?) } + + validates :base_image, :timezone, + format: { without: UNSAFE_CHARACTERS, message: "contains invalid characters" }, + allow_blank: true + # Surfaces child GtfsRtFeed errors (e.g. a single quote in a URL) up through the + # array via StoreModel's array validation strategy. WITHOUT this, an invalid feed + # passes validation, gets serialized, and breaks the single-quoted Dockerfile + # line — the exact failure §Validation claims to prevent. + validates :feeds, store_model: { merge_errors: true } +end +``` + +### Backfill migration + +A data migration that, for every `AppConfig` whose `dockerfile_config` JSONB still has flat feed keys and an empty `feeds`, moves those values into `feeds[0]`. Idempotent; safe to run on an empty/greenfield dataset. + +```ruby +class BackfillDockerfileConfigFeeds < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + AppConfig.find_each do |app_config| + raw = app_config.read_attribute_before_type_cast(:dockerfile_config) + cfg = raw.is_a?(String) ? JSON.parse(raw) : (raw || {}) + next if cfg["feeds"].present? + + flat_keys = %w[alerts_url trip_updates_url vehicle_positions_url + refresh_interval agency_id_list feed_api_key feed_api_value] + # "Real" data excludes refresh_interval — it defaults to "30" on EVERY row + # (the JSONB serializes all attributes), so counting it would fabricate a + # junk feed[0] on configs that never set up a feed. + real_keys = flat_keys - %w[refresh_interval] + next if real_keys.all? { |k| cfg[k].blank? } + + feed = cfg.slice(*flat_keys).compact + feed["refresh_interval"] = feed["refresh_interval"].presence || "30" + app_config.update_columns( + dockerfile_config: cfg.except(*flat_keys).merge("feeds" => [feed]) + ) + end + end + + def down + # no-op: the flat columns no longer exist on the model + end +end +``` + +*(Exact attribute access to be confirmed against StoreModel during implementation; the intent is "copy flat → feeds[0], drop flat keys, leave already-migrated rows alone.")* + +### Controller: `app/controllers/organizations/app_configs_controller.rb` + +No `:id` (StoreModel feeds have no persistent id) and no `:_destroy` (removal is DOM-only; the array is replaced wholesale from the submitted rows) — matching the regions/bounds precedent. + +```ruby +DOCKERFILE_CONFIG_FIELDS = [ + :base_image, :timezone, + { feeds_attributes: %i[label trip_updates_url vehicle_positions_url + alerts_url refresh_interval agency_id_list + feed_api_key feed_api_value] } +].freeze + +def dockerfile_config_params + params.require(:app_config).permit(dockerfile_config: DOCKERFILE_CONFIG_FIELDS) +end +``` + +### Service wizard: `ServiceWizards::SaveConfigStep` + wizard `config_params` + +**This is required, not optional** — the onboarding wizard also writes the flat feed fields today, so once `DockerfileConfig` drops them, the wizard crashes with `ActiveModel::UnknownAttributeError`. The wizard collects a *single* feed (the session's `rt_feeds` are keyed by kind: `trip_updates` / `vehicle_positions` / `alerts` — all one feed), so it maps cleanly to `feeds[0]`; **onboarding stays single-feed in the UI**, only the written shape changes. + +- `app/commands/service_wizards/save_config_step.rb` (`build_app_config`, line ~62): instead of `dockerfile_config: overrides.merge(rt_urls_from_session)`, build `dockerfile_config: { base_image:, timezone:, feeds: [ { trip_updates_url:, vehicle_positions_url:, alerts_url:, refresh_interval:, agency_id_list:, feed_api_key:, feed_api_value: } ] }` — drop the all-blank feed if onboarding provided no URLs. +- `app/controllers/organizations/service_wizards_controller.rb` (`config_params`, lines ~86–90): replace the flat `dockerfile_config: [...]` permit with the nested `feeds`/`feeds_attributes` shape. +- Existing wizard specs assume flat fields and will fail: `spec/system/service_wizard_flow_spec.rb` and `spec/requests/organizations/service_wizards_spec.rb` — update both. + +### Generator: `app/commands/dockerfiles/generate_oba_api.rb` + +Replace the six single-feed `ENV` lines (current lines 18–24) with one `GTFS_RT_FEEDS` line, emitted only when at least one feed has content. + +The global `ENV REFRESH_INTERVAL` line is **dropped** — refresh interval is now per-feed inside the JSON. (`REFRESH_INTERVAL` still works as a legacy single-feed var in the image, so nothing breaks.) + +```ruby +lines << env_line("GTFS_URL", gtfs_url) if gtfs_url.present? +lines << gtfs_rt_feeds_line if feeds_json.present? +lines << env_line("REVISION", @config.revision.to_s) + +# ... + +def feeds_json + payload = @config.feeds.filter_map do |feed| + obj = { + "tripUpdatesUrl" => feed.trip_updates_url, + "vehiclePositionsUrl" => feed.vehicle_positions_url, + "alertsUrl" => feed.alerts_url, + "refreshInterval" => feed.refresh_interval, + "agencyIds" => parse_agency_ids(feed.agency_id_list), + "feedApiKey" => feed.feed_api_key, + "feedApiValue" => feed.feed_api_value + }.reject { |_k, v| v.blank? } + obj if obj.key?("tripUpdatesUrl") || obj.key?("vehiclePositionsUrl") + end + payload.present? ? JSON.generate(payload) : nil +end + +def gtfs_rt_feeds_line + "ENV GTFS_RT_FEEDS='#{feeds_json}'" # single-quoted, like AGENCY_ID_LIST +end + +def parse_agency_ids(raw) + return [] if raw.blank? + parsed = JSON.parse(raw) rescue nil + parsed.is_a?(Array) ? parsed : [] +end +``` + +`JSON.generate` escapes the double quotes inside the value; wrapping the whole thing in single quotes (as `AGENCY_ID_LIST` already does) keeps the Dockerfile line valid. The `GtfsRtFeed` validation forbids literal single quotes, which is the only character that could break the single-quote wrapper. `label` is intentionally never serialized. + +### UI: `app/views/organizations/app_configs/edit_dockerfile_config.html.erb` + +Global fields up top, then a feeds section using the established nested-form pattern (`