diff --git a/.github/workflows/47-railway-template-drift.yml b/.github/workflows/47-railway-template-drift.yml new file mode 100644 index 0000000000..9f889f2008 --- /dev/null +++ b/.github/workflows/47-railway-template-drift.yml @@ -0,0 +1,65 @@ +# Railway preview template drift check (issue #5650). +# +# The preview TEMPLATE environment (per-PR previews are clones of it) is +# defined in git: hosting/railway/oss/template/template.json. This workflow +# runs the converge tool in --dry-run mode on a schedule and fails loudly when +# the live environment no longer matches the committed definition — i.e. when +# someone hand-edited the template in the Railway dashboard, or a template +# change merged without being applied. +# +# It never mutates anything: --dry-run only reads. Reads cost ~18 API calls +# against the shared token's hourly budget (Hobby tier: 1000/hour), so the +# schedule is offset from workflow 45's 06:00 UTC cleanup cron. +# +# Auth traps (same as workflow 46): +# 1. The secret must be exported as RAILWAY_API_TOKEN, never RAILWAY_TOKEN: +# the Railway CLI treats a variable literally named RAILWAY_TOKEN as +# PROJECT-scoped and account-level calls fail Unauthorized (#4392). +# 2. The token must be an ACCOUNT token (workspace tokens cannot resolve +# `me`, which the project lookup iterates). + +name: "47 - railway template drift" + +on: + schedule: + # Daily at 06:30 UTC — offset from workflow 45's 06:00 cleanup cron so the + # two never contend for the same hourly API budget window. + - cron: "30 6 * * *" + workflow_dispatch: + inputs: + env_name: + description: "Environment to check (default: the template, pr-template)" + required: false + type: string + default: "pr-template" + +permissions: + contents: read + +env: + # Trap 1: account token exported as RAILWAY_API_TOKEN, never RAILWAY_TOKEN. + RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + +jobs: + drift-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + + - name: Check template environment against committed definition + run: | + set -uo pipefail + env_name='${{ inputs.env_name || 'pr-template' }}' + rc=0 + hosting/railway/oss/template/apply.sh --dry-run --env-name "$env_name" || rc=$? + if [ "$rc" -eq 0 ]; then + echo "Template environment '$env_name' matches hosting/railway/oss/template/template.json." + exit 0 + fi + if [ "$rc" -eq 2 ]; then + echo "::error::Railway template environment '$env_name' has DRIFTED from hosting/railway/oss/template/template.json (drift report above). Hand-edits must be reverted (run apply.sh) or folded into a PR that updates the definition." + else + echo "::error::Drift check could not complete (exit $rc). See the log above." + fi + exit "$rc" diff --git a/hosting/railway/oss/template/README.md b/hosting/railway/oss/template/README.md new file mode 100644 index 0000000000..c4bf73cf45 --- /dev/null +++ b/hosting/railway/oss/template/README.md @@ -0,0 +1,149 @@ +# Railway preview template (template-as-code) + +Per-PR preview environments on Railway are created by **cloning a template +environment** (design: issue #5650; live proof: +`docs/design/railway-preview-clone-spike/`). This directory makes that template +code in the repo: + +- `template.json` — the full definition of the template environment: 13 + services with images (tags parameterized), startCommands, restart policies, + volumes, healthcheck policy, deploy-ordering constraints, and every managed + variable by NAME. It contains **no secret values**. +- `apply.sh` — idempotent converge of a live environment to the definition, + driving Railway's GraphQL API. `--dry-run` prints a structured diff and exits + nonzero (2) on drift; the default mode applies the delta. +- `lib-graphql.sh` — the GraphQL client (redaction, bounded retries, call + accounting). Productionized copy of the spike client so this tooling stays + decoupled from `../scripts/` (the legacy per-PR path). +- `.github/workflows/47-railway-template-drift.yml` — daily scheduled + `apply.sh --dry-run` against the template; fails loudly on drift. + +The template currently lives in project `agenta-oss-clone-spike`, environment +`pr-template` (the rollout step re-points `project` in `template.json` — +marked `TODO(WP3-project)`). + +## How to change the template + +Template changes are **pull requests**, exactly like DB migrations, and follow +the same additive-first discipline: + +1. **Edit `template.json` in a PR.** Never hand-edit the template in the + Railway dashboard: the drift check will page on it, and hand-edits are not + reviewable or reproducible. +2. **Order changes additive-first relative to code PRs.** A new variable or + service lands in the template *before* the code that requires it (old code + ignores the extra config); removal lands *after* no supported code path + needs it. This keeps every open PR's clone deployable throughout. +3. **Test on a clone first.** Create a scratch clone of `pr-template` (or reuse + a preview clone), then converge *it* against your edited definition: + + ```bash + hosting/railway/oss/template/apply.sh --env-name my-scratch-clone + ``` + + Deploy/smoke the clone before touching the template itself. +4. **Apply on merge.** After the PR merges, run `apply.sh` (no flags) against + the template. Until this step runs, the daily drift check fails by design — + a merged-but-unapplied definition IS drift. +5. **Drift check catches hand-edits.** Workflow 47 runs `--dry-run` daily. + When it fails: if the live change was accidental, run `apply.sh` to revert + it; if it was intentional, fold it into a PR that updates `template.json`, + merge, and the next run goes green. + +## Running apply locally + +```bash +# Diff only (exit 0 clean / 2 drift / 1 error). Never mutates. +hosting/railway/oss/template/apply.sh --dry-run + +# Converge the template to the committed definition. +hosting/railway/oss/template/apply.sh + +# Converge a named environment (e.g. a scratch clone for testing a change). +hosting/railway/oss/template/apply.sh --env-name my-scratch-clone + +# Override image tag parameters (see "Image tags" below). +hosting/railway/oss/template/apply.sh --app-tag v0.108.0 --wrapper-tag +``` + +Auth: an **account** token in `RAILWAY_API_TOKEN` (auto-sourced from +`~/.agenta-railway.env`; in CI, exported from `secrets.RAILWAY_TOKEN`). Never +name the variable `RAILWAY_TOKEN` — the Railway CLI treats that name as +project-scoped and account-level calls fail Unauthorized. + +Cost: a dry-run is ~18 API calls (instances + volumes in one query, plus one +variables query per service) against the token's 1000/hour Hobby budget. + +## Image tags + +Two parameters in `template.json` (overridable via `--app-tag`/`--wrapper-tag` +or `AGENTA_TEMPLATE_APP_TAG`/`AGENTA_PREVIEW_WRAPPER_TAG`): + +- `app_tag` — the four Agenta app images (`agenta-api`, `agenta-web`, + `agenta-services`, `agenta-runner`), pinned to a release tag. Clones patch + these to `pr--` tags per PR via `environmentPatchCommit`. +- `wrapper_tag` — the three preview wrapper images + (`ghcr.io/agenta-ai/agenta-preview-{gateway,redis,seaweedfs}`, built by + workflow 42 / WP1). Placeholder default `spike` until WP1's content-addressed + tag is pinned (`TODO(WP1-tag)` in `template.json`). + +**Template tags must never be `latest` and never a `pr-*` tag.** +`environmentPatchCommit` silently no-ops when a patched image:tag equals the +template's, which strands a clone on template images (proven live; see +findings.md "deploy-mode findings"). `apply.sh` refuses both. + +## Secrets and variable conventions + +`template.json` stores variables in three shapes: + +- **Reference values** (contain `${{...}}`) are per-environment-unique: + Railway re-resolves them inside every clone (e.g. + `https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}`, `${{Postgres.POSTGRES_PASSWORD}}`). +- **Literal values** are shared, non-secret config (ports, internal URLs, + `AGENTA_LICENSE=oss`), byte-identical in template and clones. +- **Secrets** appear as `{"secret": "NAME"}` referencing the top-level + `secrets` map, which declares only the *resolution*, never the value: + `from_env` (operator-provided env var wins) and `generate` (e.g. + `openssl-rand-hex-32`, `openssl-genpkey-rsa-2048`). At apply time a secret is + resolved only when a variable is missing live, preferring the value already + live on a sibling service (so shared secrets stay consistent), and is never + printed — diff output carries variable **names only**. + +`optionalVariables` lists names an operator may set without tripping the drift +check (e.g. `POSTHOG_API_KEY`, `AGENTA_RUNNER_DAYTONA_*`). Any other +undeclared variable is drift and is deleted by apply. Railway-injected +`RAILWAY_*` variables are ignored unless explicitly declared (the definition +declares `RAILWAY_RUN_UID/GID` on redis and +`RAILWAY_DEPLOYMENT_DRAINING_SECONDS` on Postgres, which are user-set). + +## What apply will and will not do + +| Drift | dry-run | apply | +|---|---|---| +| Missing service | reported | `serviceCreate` (check-then-act + verify poll) | +| Image mismatch | reported | `serviceInstanceUpdate` | +| startCommand violation | reported | cleared with `""` (API fact: `null` is a no-op) or set | +| Restart-policy mismatch | reported | `serviceInstanceUpdate` | +| Missing variable | name reported | `variableCollectionUpsert` (skipDeploys, merge) | +| Undeclared variable | name reported | `variableDelete` | +| Missing volume | reported | `volumeCreate` (check-then-act) | +| **Extra service / extra volume** | reported | **never deleted** — destructive; remove manually via a documented PR, then re-run | + +Apply changes **configuration only**; it never triggers deployments (template +deployments are irrelevant to clones — clones copy config, not deployments). + +## Policy notes + +- **startCommand:** services whose image owns its entrypoint (Postgres, + supertokens, and the three wrapper images) must have it empty/unset. The + eight app services carry explicit startCommands because one image backs + several services (`agenta-api` alone backs api, worker-streams, + worker-queues, cron, alembic). +- **Healthchecks:** unset on all services, matching the live-proven template + (10/10 green clone cycles). The legacy per-PR path (`../scripts/configure.sh`) + sets healthchecks on gateway/api/services/runner; adding them to the template + is a deliberate WP3 decision, not silent drift. +- **Deploy order** (for anything deploying a fresh clone): infra + (Postgres/redis/seaweedfs) → alembic → everything else; supertokens must not + start before alembic has created its database. A single Postgres + first-deploy timeout in a fresh clone is retryable, not fatal. diff --git a/hosting/railway/oss/template/apply.sh b/hosting/railway/oss/template/apply.sh new file mode 100755 index 0000000000..a29817b0ab --- /dev/null +++ b/hosting/railway/oss/template/apply.sh @@ -0,0 +1,547 @@ +#!/usr/bin/env bash + +# Converge a live Railway environment to the committed template definition +# (template.json). This is the change-management tool for the preview TEMPLATE +# environment (issue #5650): the definition in git is the source of truth, this +# script makes reality match it, and workflow 47 runs `--dry-run` on a schedule +# to catch hand-edits. +# +# Usage: +# apply.sh [--dry-run] [--env-name NAME] [--project NAME] +# [--app-tag TAG] [--wrapper-tag TAG] [--definition FILE] +# +# --dry-run Print a structured diff (missing services, image mismatches, +# variable NAME diffs — never values —, volume diffs, +# startCommand violations). Exit 0 when clean, 2 when drift +# exists, 1 on errors. +# (default) Apply the delta: create missing services, fix images / +# startCommands / restart policies, upsert missing variables +# (variableCollectionUpsert, skipDeploys), delete undeclared +# variables, create missing volumes. Extra SERVICES and extra +# VOLUMES are reported but never deleted (destructive; removing +# them is a manual, PR-documented operation), so apply exits +# nonzero while they remain. +# --env-name Target environment (default: templateEnvironment from the +# definition, i.e. pr-template). Point it at a scratch clone to +# test a definition change before touching the template. +# --app-tag / Override the image tag parameters (defaults from the +# --wrapper-tag definition). Guarded: never 'latest', never pr-* (the +# environmentPatchCommit no-op trap; see template.json notes). +# +# Env vars: RAILWAY_API_TOKEN (account token; auto-sourced from +# ~/.agenta-railway.env), RAILWAY_PROJECT_NAME / RAILWAY_ENVIRONMENT_NAME / +# AGENTA_TEMPLATE_APP_TAG / AGENTA_PREVIEW_WRAPPER_TAG as flag fallbacks. +# +# Secrets discipline: this file and template.json contain NO secret values. +# Secret variables are declared by NAME with a resolution spec (from_env / +# reuse-live / generate); values are resolved only when a variable is missing +# live, are reused from a sibling service when possible so shared secrets stay +# consistent, and are NEVER printed (diff lines carry names only; failure +# output goes through rw_redact). +# +# Proven API facts this script relies on (docs/design/railway-preview-clone-spike/ +# findings.md): workspace-scoped project lookup; serviceInstanceUpdate applies +# immediately and does NOT deploy; startCommand null is a no-op while "" +# clears; creates (serviceCreate/volumeCreate) are not idempotent, so they are +# check-then-act with RW_NO_TRANSIENT_RETRY and a verify read. + +set -euo pipefail + +TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib-graphql.sh +source "$TEMPLATE_DIR/lib-graphql.sh" + +DEFINITION="$TEMPLATE_DIR/template.json" +DRY_RUN=false +ENV_NAME_ARG="" +PROJECT_ARG="" +APP_TAG_ARG="" +WRAPPER_TAG_ARG="" + +die() { + printf 'apply.sh: %s\n' "$*" >&2 + exit 1 +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --env-name) [ $# -ge 2 ] || die "--env-name needs a value"; ENV_NAME_ARG="$2"; shift ;; + --env-name=*) ENV_NAME_ARG="${1#*=}" ;; + --project) [ $# -ge 2 ] || die "--project needs a value"; PROJECT_ARG="$2"; shift ;; + --project=*) PROJECT_ARG="${1#*=}" ;; + --app-tag) [ $# -ge 2 ] || die "--app-tag needs a value"; APP_TAG_ARG="$2"; shift ;; + --app-tag=*) APP_TAG_ARG="${1#*=}" ;; + --wrapper-tag) [ $# -ge 2 ] || die "--wrapper-tag needs a value"; WRAPPER_TAG_ARG="$2"; shift ;; + --wrapper-tag=*) WRAPPER_TAG_ARG="${1#*=}" ;; + --definition) [ $# -ge 2 ] || die "--definition needs a value"; DEFINITION="$2"; shift ;; + --definition=*) DEFINITION="${1#*=}" ;; + -h|--help) sed -n '3,40p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; + esac + shift +done + +for cmd in jq curl openssl; do + command -v "$cmd" >/dev/null 2>&1 || die "missing required command: $cmd" +done +[ -f "$DEFINITION" ] || die "definition not found: $DEFINITION" +jq -e . <"$DEFINITION" >/dev/null || die "definition is not valid JSON: $DEFINITION" + +PROJECT_NAME="${PROJECT_ARG:-${RAILWAY_PROJECT_NAME:-$(jq -r '.project' "$DEFINITION")}}" +ENV_NAME="${ENV_NAME_ARG:-${RAILWAY_ENVIRONMENT_NAME:-$(jq -r '.templateEnvironment' "$DEFINITION")}}" +APP_TAG="${APP_TAG_ARG:-${AGENTA_TEMPLATE_APP_TAG:-$(jq -r '.parameters.app_tag.default' "$DEFINITION")}}" +WRAPPER_TAG="${WRAPPER_TAG_ARG:-${AGENTA_PREVIEW_WRAPPER_TAG:-$(jq -r '.parameters.wrapper_tag.default' "$DEFINITION")}}" + +# Per-PR preview projects (legacy path) are owned by workflows 41/43/45 and +# must never be converged by this tool. +case "$PROJECT_NAME" in + agenta-oss-pr-*) die "refusing to target per-PR preview project '$PROJECT_NAME'" ;; +esac + +# Template tags must never be 'latest' and never collide with PR tags +# (pr--): environmentPatchCommit silently no-ops when the patched tag +# equals the template's, stranding clones on template images. +validate_tag() { + local label="$1" tag="$2" + [ -n "$tag" ] && [ "$tag" != "null" ] || die "$label is empty" + [ "$tag" != "latest" ] || die "$label must never be 'latest' (patchCommit no-op trap; template.json notes.tag-policy)" + case "$tag" in + pr-*) die "$label must never use a pr-* tag (collides with PR image tags; patchCommit no-op trap)" ;; + esac +} +validate_tag "app_tag" "$APP_TAG" +validate_tag "wrapper_tag" "$WRAPPER_TAG" + +# Desired state with image tag parameters substituted. +DESIRED="$(jq -c --arg app "$APP_TAG" --arg wrap "$WRAPPER_TAG" \ + '.services | with_entries(.value.image |= (gsub("\\{app_tag\\}"; $app) | gsub("\\{wrapper_tag\\}"; $wrap)))' \ + "$DEFINITION")" + +rw_require_token + +PROJECT_ID="$(rw_find_project_id "$PROJECT_NAME" || true)" +[ -n "$PROJECT_ID" ] || die "project '$PROJECT_NAME' not found for this token" + +ENV_QUERY='query($p: String!) { environments(projectId: $p, first: 50) { edges { node { id name } } } }' +ENV_ID="$(rw_graphql "$ENV_QUERY" "$(jq -nc --arg p "$PROJECT_ID" '{p: $p}')" \ + | jq -r --arg n "$ENV_NAME" '.data.environments.edges[].node | select(.name == $n) | .id' | head -n1)" +[ -n "$ENV_ID" ] || die "environment '$ENV_NAME' not found in project '$PROJECT_NAME'" + +printf 'Target: project=%s environment=%s app_tag=%s wrapper_tag=%s mode=%s\n' \ + "$PROJECT_NAME" "$ENV_NAME" "$APP_TAG" "$WRAPPER_TAG" \ + "$([ "$DRY_RUN" = true ] && printf 'dry-run' || printf 'apply')" + +# --------------------------------------------------------------------------- +# Live-state snapshot +# --------------------------------------------------------------------------- + +LIVE_ENV_JSON="" +declare -A LIVE_SID=() # serviceName -> serviceId +declare -A LIVE_VARS=() # serviceName -> space-separated variable names + +INSTANCES_QUERY='query($id: String!) { environment(id: $id) { serviceInstances { edges { node { serviceId serviceName source { image } startCommand healthcheckPath restartPolicyType restartPolicyMaxRetries } } } volumeInstances { edges { node { mountPath serviceId } } } } }' +VARIABLES_QUERY='query($p: String!, $e: String!, $s: String!) { variables(projectId: $p, environmentId: $e, serviceId: $s) }' + +# contains_word +contains_word() { + case " $1 " in *" $2 "*) return 0 ;; esac + return 1 +} + +load_live_state() { + LIVE_ENV_JSON="$(rw_graphql "$INSTANCES_QUERY" "$(jq -nc --arg id "$ENV_ID" '{id: $id}')" \ + | jq -c '.data.environment')" + [ -n "$LIVE_ENV_JSON" ] && [ "$LIVE_ENV_JSON" != "null" ] || die "could not read environment state" + + LIVE_SID=() + LIVE_VARS=() + local name sid + while IFS=$'\t' read -r name sid; do + LIVE_SID["$name"]="$sid" + done < <(jq -r '.serviceInstances.edges[].node | [.serviceName, .serviceId] | @tsv' <<<"$LIVE_ENV_JSON") + + # Variable NAMES per managed service (values are fetched but immediately + # reduced to keys and never printed). + for name in $(jq -r 'keys[]' <<<"$DESIRED"); do + sid="${LIVE_SID[$name]:-}" + [ -n "$sid" ] || continue + LIVE_VARS["$name"]="$(rw_graphql "$VARIABLES_QUERY" \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$sid" '{p: $p, e: $e, s: $s}')" \ + | jq -r '.data.variables // {} | keys | sort | join(" ")')" + done +} + +# --------------------------------------------------------------------------- +# Diff +# --------------------------------------------------------------------------- + +DRIFT_FILE="$(mktemp "${TMPDIR:-/tmp}/rw-drift.XXXXXX")" +trap 'rm -f "$DRIFT_FILE"' EXIT + +add_drift() { + printf '%s\n' "$*" >>"$DRIFT_FILE" +} + +compute_diff() { + : >"$DRIFT_FILE" + local svc node want live sid + + for svc in $(jq -r 'keys[]' <<<"$DESIRED"); do + node="$(jq -c --arg n "$svc" \ + '[.serviceInstances.edges[].node | select(.serviceName == $n)][0] // empty' <<<"$LIVE_ENV_JSON")" + if [ -z "$node" ]; then + add_drift "DRIFT service-missing service=$svc" + continue + fi + sid="${LIVE_SID[$svc]}" + + want="$(jq -r --arg n "$svc" '.[$n].image' <<<"$DESIRED")" + live="$(jq -r '.source.image // ""' <<<"$node")" + [ "$live" = "$want" ] \ + || add_drift "DRIFT image service=$svc live=${live:-(none)} want=$want" + + # startCommand: null and "" both mean "unset" (the API treats null as + # no-change on write, so the clear value is ""). + want="$(jq -r --arg n "$svc" '.[$n].startCommand // ""' <<<"$DESIRED")" + live="$(jq -r '.startCommand // ""' <<<"$node")" + [ "$live" = "$want" ] \ + || add_drift "DRIFT start-command service=$svc live=${live:-(unset)} want=${want:-(unset)}" + + want="$(jq -r --arg n "$svc" '.[$n].healthcheckPath // ""' <<<"$DESIRED")" + live="$(jq -r '.healthcheckPath // ""' <<<"$node")" + [ "$live" = "$want" ] \ + || add_drift "DRIFT healthcheck service=$svc live=${live:-(unset)} want=${want:-(unset)}" + + want="$(jq -r --arg n "$svc" '.[$n].restartPolicyType // ""' <<<"$DESIRED")" + live="$(jq -r '.restartPolicyType // ""' <<<"$node")" + [ -z "$want" ] || [ "$live" = "$want" ] \ + || add_drift "DRIFT restart-policy service=$svc live=$live want=$want" + + want="$(jq -r --arg n "$svc" '.[$n].restartPolicyMaxRetries // ""' <<<"$DESIRED")" + live="$(jq -r '.restartPolicyMaxRetries // ""' <<<"$node")" + [ -z "$want" ] || [ "$live" = "$want" ] \ + || add_drift "DRIFT restart-retries service=$svc live=$live want=$want" + + # Variables: names only. Railway-injected RAILWAY_* names are ignored + # unless the definition declares them (e.g. RAILWAY_RUN_UID). + local declared optional live_names name + declared="$(jq -r --arg n "$svc" '.[$n].variables // {} | keys | join(" ")' <<<"$DESIRED")" + optional="$(jq -r --arg n "$svc" '.[$n].optionalVariables // [] | join(" ")' <<<"$DESIRED")" + live_names="${LIVE_VARS[$svc]:-}" + for name in $declared; do + contains_word "$live_names" "$name" \ + || add_drift "DRIFT var-missing service=$svc name=$name" + done + for name in $live_names; do + if ! contains_word "$declared" "$name"; then + case "$name" in + RAILWAY_*) continue ;; + esac + contains_word "$optional" "$name" \ + || add_drift "DRIFT var-extra service=$svc name=$name" + fi + done + + # Volumes: identity is (service, mountPath). + local mp live_mounts want_mounts + want_mounts="$(jq -r --arg n "$svc" '.[$n].volumes // [] | join(" ")' <<<"$DESIRED")" + live_mounts="$(jq -r --arg s "$sid" \ + '[.volumeInstances.edges[].node | select(.serviceId == $s) | .mountPath] | join(" ")' <<<"$LIVE_ENV_JSON")" + for mp in $want_mounts; do + contains_word "$live_mounts" "$mp" \ + || add_drift "DRIFT volume-missing service=$svc mountPath=$mp" + done + for mp in $live_mounts; do + contains_word "$want_mounts" "$mp" \ + || add_drift "DRIFT volume-extra service=$svc mountPath=$mp (never auto-deleted; remove manually if intended)" + done + done + + # Services present live but absent from the definition. Reported as drift; + # apply never deletes services (destructive) — decommissioning is a manual, + # PR-documented operation. + local live_svc + for live_svc in "${!LIVE_SID[@]}"; do + jq -e --arg n "$live_svc" 'has($n)' <<<"$DESIRED" >/dev/null \ + || add_drift "DRIFT service-extra service=$live_svc (never auto-deleted; remove manually if intended)" + done +} + +print_report() { + if [ -s "$DRIFT_FILE" ]; then + printf -- '--- drift report (%s line(s)) ---\n' "$(wc -l <"$DRIFT_FILE" | tr -d ' ')" + sort "$DRIFT_FILE" + printf -- '--- end drift report ---\n' + else + printf 'CLEAN: live environment matches the definition.\n' + fi +} + +# --------------------------------------------------------------------------- +# Secret resolution (apply mode only; values never printed) +# --------------------------------------------------------------------------- + +declare -A SECRET_CACHE=() +RESOLVED_VALUE="" + +# resolve_secret_into : sets RESOLVED_VALUE. Resolution order +# mirrors scripts/configure.sh: explicit operator value (env var) wins; else +# reuse the value already live on a sibling service that declares the same +# secret (keeps shared secrets consistent across services); else generate. +resolve_secret_into() { + local sname="$1" + if [ -n "${SECRET_CACHE[$sname]+x}" ]; then + RESOLVED_VALUE="${SECRET_CACHE[$sname]}" + return 0 + fi + + local spec from_env gen val="" + spec="$(jq -c --arg n "$sname" '.secrets[$n] // empty' "$DEFINITION")" + [ -n "$spec" ] || die "variable references undeclared secret '$sname'" + + from_env="$(jq -r '.from_env // empty' <<<"$spec")" + if [ -n "$from_env" ] && [ -n "${!from_env:-}" ]; then + val="${!from_env}" + printf 'secret %s: resolved from environment variable %s\n' "$sname" "$from_env" >&2 + fi + + if [ -z "$val" ]; then + # Reuse from a live sibling: find (service, variableName) pairs mapped + # to this secret and read the first value that exists live. + local svc varname + while IFS=$'\t' read -r svc varname; do + [ -n "${LIVE_SID[$svc]:-}" ] || continue + contains_word "${LIVE_VARS[$svc]:-}" "$varname" || continue + val="$(rw_graphql "$VARIABLES_QUERY" \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "${LIVE_SID[$svc]}" '{p: $p, e: $e, s: $s}')" \ + | jq -r --arg k "$varname" '.data.variables[$k] // empty')" + if [ -n "$val" ]; then + printf 'secret %s: reusing live value from service %s\n' "$sname" "$svc" >&2 + break + fi + done < <(jq -r --arg s "$sname" \ + 'to_entries[] | .key as $svc | (.value.variables // {}) | to_entries[] + | select((.value | type == "object") and .value.secret == $s) + | [$svc, .key] | @tsv' <<<"$DESIRED") + fi + + if [ -z "$val" ]; then + gen="$(jq -r '.generate // empty' <<<"$spec")" + case "$gen" in + openssl-rand-hex-*) val="$(openssl rand -hex "${gen##*-}")" ;; + openssl-rand-base64-*) val="$(openssl rand -base64 "${gen##*-}")" ;; + openssl-genpkey-rsa-2048) val="$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null)" ;; + "") die "secret '$sname' is not set in the environment and has no generator" ;; + *) die "secret '$sname' has unknown generator spec '$gen'" ;; + esac + printf 'secret %s: generated (%s)\n' "$sname" "$gen" >&2 + fi + + SECRET_CACHE[$sname]="$val" + RESOLVED_VALUE="$val" +} + +# resolve_variable_value_into : sets RESOLVED_VALUE. +resolve_variable_value_into() { + local svc="$1" name="$2" spec + spec="$(jq -c --arg n "$svc" --arg k "$name" '.[$n].variables[$k]' <<<"$DESIRED")" + if [ "$(jq -r 'type' <<<"$spec")" = "string" ]; then + RESOLVED_VALUE="$(jq -r '.' <<<"$spec")" + return 0 + fi + resolve_secret_into "$(jq -r '.secret // empty' <<<"$spec")" +} + +# --------------------------------------------------------------------------- +# Apply +# --------------------------------------------------------------------------- + +# create_missing_service : serviceCreate is NOT idempotent — the caller +# already confirmed absence from a fresh read (check), the create is guarded +# against blind transient retry (act), and the result is verified by polling +# the environment until the service appears (verify). +create_missing_service() { + local svc="$1" img + img="$(jq -r --arg n "$svc" '.[$n].image' <<<"$DESIRED")" + printf 'creating service %s (image %s)\n' "$svc" "$img" + RW_NO_TRANSIENT_RETRY=1 rw_graphql \ + 'mutation($in: ServiceCreateInput!) { serviceCreate(input: $in) { id } }' \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg n "$svc" --arg img "$img" \ + '{in: {projectId: $p, environmentId: $e, name: $n, source: {image: $img}}}')" \ + >/dev/null || printf 'serviceCreate for %s reported failure; verifying by polling.\n' "$svc" >&2 + + local waited=0 found="" + while [ -z "$found" ] && [ "$waited" -lt 90 ]; do + found="$(rw_graphql "$INSTANCES_QUERY" "$(jq -nc --arg id "$ENV_ID" '{id: $id}')" \ + | jq -r --arg n "$svc" \ + '[.data.environment.serviceInstances.edges[].node | select(.serviceName == $n) | .serviceId][0] // empty')" + if [ -z "$found" ]; then + sleep 10 + waited=$((waited + 10)) + fi + done + [ -n "$found" ] || die "service '$svc' still missing after create + 90s verify" +} + +# apply_instance_patch : one serviceInstanceUpdate carrying only the +# drifted fields. startCommand is cleared with "" (null is a no-op). +apply_instance_patch() { + local svc="$1" + local sid="${LIVE_SID[$svc]}" input='{}' + local changed=false line field + + while IFS= read -r line; do + field="${line#DRIFT }" + field="${field%% *}" + case "$field" in + image) + input="$(jq -c --arg v "$(jq -r --arg n "$svc" '.[$n].image' <<<"$DESIRED")" \ + '. + {source: {image: $v}}' <<<"$input")" + changed=true ;; + start-command) + input="$(jq -c --arg v "$(jq -r --arg n "$svc" '.[$n].startCommand // ""' <<<"$DESIRED")" \ + '. + {startCommand: $v}' <<<"$input")" + changed=true ;; + healthcheck) + # Setting a path is proven; clearing with "" mirrors the + # startCommand workaround but is best-effort (unverified). + input="$(jq -c --arg v "$(jq -r --arg n "$svc" '.[$n].healthcheckPath // ""' <<<"$DESIRED")" \ + '. + {healthcheckPath: $v}' <<<"$input")" + changed=true ;; + restart-policy) + input="$(jq -c --arg v "$(jq -r --arg n "$svc" '.[$n].restartPolicyType' <<<"$DESIRED")" \ + '. + {restartPolicyType: $v}' <<<"$input")" + changed=true ;; + restart-retries) + input="$(jq -c --argjson v "$(jq -r --arg n "$svc" '.[$n].restartPolicyMaxRetries' <<<"$DESIRED")" \ + '. + {restartPolicyMaxRetries: $v}' <<<"$input")" + changed=true ;; + esac + done < <(grep -E "^DRIFT (image|start-command|healthcheck|restart-policy|restart-retries) service=$svc " "$DRIFT_FILE" || true) + + [ "$changed" = true ] || return 0 + printf 'patching service config: %s (%s)\n' "$svc" "$(jq -r 'keys | join(",")' <<<"$input")" + rw_graphql \ + 'mutation($s: String!, $e: String!, $in: ServiceInstanceUpdateInput!) { serviceInstanceUpdate(serviceId: $s, environmentId: $e, input: $in) }' \ + "$(jq -nc --arg s "$sid" --arg e "$ENV_ID" --argjson in "$input" '{s: $s, e: $e, in: $in}')" \ + >/dev/null +} + +# apply_missing_vars : one variableCollectionUpsert (skipDeploys, +# merge) carrying every missing variable for the service. +apply_missing_vars() { + local svc="$1" + local sid="${LIVE_SID[$svc]}" name vars_json='{}' count=0 + while IFS= read -r name; do + resolve_variable_value_into "$svc" "$name" + vars_json="$(jq -c --arg k "$name" --arg v "$RESOLVED_VALUE" '. + {($k): $v}' <<<"$vars_json")" + count=$((count + 1)) + done < <(grep -E "^DRIFT var-missing service=$svc " "$DRIFT_FILE" | sed -E 's/.* name=//' || true) + [ "$count" -gt 0 ] || return 0 + printf 'upserting %d variable(s) on %s\n' "$count" "$svc" + rw_graphql \ + 'mutation($in: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $in) }' \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$sid" --argjson vars "$vars_json" \ + '{in: {projectId: $p, environmentId: $e, serviceId: $s, skipDeploys: true, replace: false, variables: $vars}}')" \ + >/dev/null +} + +# apply_extra_var_deletes : delete undeclared variables (the drift +# report already named them; optionalVariables and RAILWAY_* are exempt). +apply_extra_var_deletes() { + local svc="$1" + local sid="${LIVE_SID[$svc]}" name + while IFS= read -r name; do + printf 'deleting undeclared variable %s on %s\n' "$name" "$svc" + rw_graphql \ + 'mutation($in: VariableDeleteInput!) { variableDelete(input: $in) }' \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$sid" --arg n "$name" \ + '{in: {projectId: $p, environmentId: $e, serviceId: $s, name: $n}}')" \ + >/dev/null + done < <(grep -E "^DRIFT var-extra service=$svc " "$DRIFT_FILE" | sed -E 's/.* name=//' || true) +} + +# apply_missing_volumes : volumeCreate is NOT idempotent — re-check +# right before creating (a prior ambiguous timeout may have succeeded). +apply_missing_volumes() { + local svc="$1" + local sid="${LIVE_SID[$svc]}" mp existing + while IFS= read -r mp; do + existing="$(rw_graphql "$INSTANCES_QUERY" "$(jq -nc --arg id "$ENV_ID" '{id: $id}')" \ + | jq -r --arg s "$sid" --arg mp "$mp" \ + '[.data.environment.volumeInstances.edges[].node | select(.serviceId == $s and .mountPath == $mp)] | length')" + if [ "$existing" != "0" ]; then + printf 'volume %s on %s already exists; skipping create\n' "$mp" "$svc" + continue + fi + printf 'creating volume %s on %s\n' "$mp" "$svc" + RW_NO_TRANSIENT_RETRY=1 rw_graphql \ + 'mutation($in: VolumeCreateInput!) { volumeCreate(input: $in) { id } }' \ + "$(jq -nc --arg p "$PROJECT_ID" --arg e "$ENV_ID" --arg s "$sid" --arg mp "$mp" \ + '{in: {projectId: $p, environmentId: $e, serviceId: $s, mountPath: $mp}}')" \ + >/dev/null || printf 'volumeCreate for %s reported failure; the final verify pass re-checks.\n' "$mp" >&2 + done < <(grep -E "^DRIFT volume-missing service=$svc " "$DRIFT_FILE" | sed -E 's/.* mountPath=//' || true) +} + +apply_drift() { + local svc + + # Pass 1: create missing services, then refresh the snapshot so the rest + # of the converge sees them. + if grep -q '^DRIFT service-missing ' "$DRIFT_FILE"; then + while IFS= read -r svc; do + create_missing_service "$svc" + done < <(grep '^DRIFT service-missing ' "$DRIFT_FILE" | sed -E 's/.* service=//') + load_live_state + compute_diff + fi + + # Pass 2: converge every managed service that exists live. + for svc in $(jq -r 'keys[]' <<<"$DESIRED"); do + [ -n "${LIVE_SID[$svc]:-}" ] || continue + apply_instance_patch "$svc" + apply_missing_vars "$svc" + apply_extra_var_deletes "$svc" + apply_missing_volumes "$svc" + done + + if grep -qE '^DRIFT (service-extra|volume-extra) ' "$DRIFT_FILE"; then + printf 'WARNING: extra services/volumes exist live; apply never deletes them (see report).\n' >&2 + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +load_live_state +compute_diff + +if [ "$DRY_RUN" = true ]; then + print_report + rw_report_calls "apply.sh --dry-run" + [ -s "$DRIFT_FILE" ] && exit 2 + exit 0 +fi + +if [ ! -s "$DRIFT_FILE" ]; then + print_report + rw_report_calls "apply.sh" + exit 0 +fi + +printf 'Drift before apply:\n' +print_report +apply_drift + +# Verify: reload and re-diff. Anything left (including extra services/volumes, +# which apply refuses to delete) keeps the exit nonzero so CI stays loud. +load_live_state +compute_diff +printf 'State after apply:\n' +print_report +rw_report_calls "apply.sh" +[ -s "$DRIFT_FILE" ] && exit 2 +exit 0 diff --git a/hosting/railway/oss/template/lib-graphql.sh b/hosting/railway/oss/template/lib-graphql.sh new file mode 100644 index 0000000000..1247b25eff --- /dev/null +++ b/hosting/railway/oss/template/lib-graphql.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +# Minimal GraphQL client for Railway's backboard API, used by the preview +# template tooling (apply.sh, workflow 47). Source this file; do not execute +# it directly. This is the productionized copy of the client proven live in +# docs/design/railway-preview-clone-spike/spike/lib-graphql.sh; keeping a copy +# here keeps the template tooling decoupled from the spike workspace and from +# the per-PR scripts in ../scripts/ (WP3 territory). +# +# Follows the idioms of hosting/railway/oss/scripts/lib.sh: +# - failure output is redacted (rw_redact mirrors _railway_redact), +# - retries are bounded and classified (429 always retried, transient network +# errors retried ONLY when the caller says the call is idempotent — a timed +# out mutation may have succeeded server-side), +# - every curl has explicit connect + total timeouts. +# +# Every HTTP request sent increments RW_CALLS_FILE so callers can report how +# many API calls a run cost against Railway's per-token hourly budget +# (Hobby tier: 1000 requests/hour, shared with the preview workflows). +# +# Environment variables: +# RAILWAY_API_TOKEN Account token. Auto-sourced from ~/.agenta-railway.env +# (format: RAILWAY_API_TOKEN=...) when unset. +# RW_TOKEN_FILE Override the token file path. +# RW_GRAPHQL_URL Endpoint (default: backboard.railway.com/graphql/v2). +# RW_GRAPHQL_TIMEOUT Per-attempt total timeout in seconds (default: 60). +# RW_RETRY_MAX Max attempts per call (default: 5). +# RW_RETRY_DELAY Initial backoff in seconds (default: 5, doubles). +# RW_NO_TRANSIENT_RETRY Set to 1 around a NON-idempotent mutation (e.g. +# serviceCreate, volumeCreate) so an ambiguous timeout +# is not blind-retried; the caller must reconcile by +# querying (check-then-act). + +# Call counter. A file, not a shell variable: callers invoke rw_graphql inside +# command substitutions (subshells), where a variable increment would be lost. +RW_CALLS_FILE="${RW_CALLS_FILE:-$(mktemp "${TMPDIR:-/tmp}/rw-calls.XXXXXX")}" +export RW_CALLS_FILE +# Caller-visible diagnostic state (last HTTP status); not read by this file. +export RW_LAST_HTTP="" +RW_LAST_HEADERS_FILE="${RW_LAST_HEADERS_FILE:-$(mktemp "${TMPDIR:-/tmp}/rw-headers.XXXXXX")}" +export RW_LAST_HEADERS_FILE + +# rw_redact: mask KEY=VALUE secrets and URL passwords before logging. +# Same sed as _railway_redact in hosting/railway/oss/scripts/lib.sh. +rw_redact() { + sed -E \ + -e 's/([A-Z0-9_]*(PASSWORD|TOKEN|SECRET|KEY)[A-Z0-9_]*[[:space:]]*=[[:space:]]*)[^[:space:]]+/\1***REDACTED***/g' \ + -e 's#(://[A-Za-z0-9._~-]+:)[^@[:space:]/]+@#\1***REDACTED***@#g' +} + +# rw_require_token: ensure RAILWAY_API_TOKEN is set, sourcing the local env +# file when needed. Never prints the token. +rw_require_token() { + local token_file="${RW_TOKEN_FILE:-$HOME/.agenta-railway.env}" + if [ -z "${RAILWAY_API_TOKEN:-}" ] && [ -f "$token_file" ]; then + # shellcheck source=/dev/null + . "$token_file" + fi + if [ -z "${RAILWAY_API_TOKEN:-}" ]; then + printf "RAILWAY_API_TOKEN is not set and %s does not provide it.\n" "$token_file" >&2 + printf "Create an ACCOUNT token at railway.com/account/tokens and store it as\n" >&2 + printf " RAILWAY_API_TOKEN=... in %s (chmod 600), or export it (CI).\n" "$token_file" >&2 + printf "Do NOT name it RAILWAY_TOKEN: the CLI treats that name as project-scoped\n" >&2 + printf "and account-level calls fail Unauthorized.\n" >&2 + return 1 + fi + export RAILWAY_API_TOKEN +} + +# _rw_retry_after : honor Retry-After when the +# server sends one (capped at 120s), else use the caller's backoff value. +_rw_retry_after() { + local hdr_file="$1" fallback="$2" ra + ra="$(grep -i '^retry-after:' "$hdr_file" 2>/dev/null | head -n1 | awk '{print $2}' | tr -d '\r')" + if printf '%s' "$ra" | grep -qE '^[0-9]+$'; then + [ "$ra" -gt 120 ] && ra=120 + printf '%s' "$ra" + else + printf '%s' "$fallback" + fi +} + +# rw_graphql [variables-json]: POST one GraphQL operation. On success +# prints the full response body (with .data) to stdout and returns 0. On +# failure prints a redacted diagnostic to stderr and returns 1. GraphQL-level +# errors (HTTP 200 + "errors") are deterministic and never retried. +rw_graphql() { + local query="$1" + local variables="${2:-}" + [ -n "$variables" ] || variables='{}' + + local endpoint="${RW_GRAPHQL_URL:-https://backboard.railway.com/graphql/v2}" + local timeout_s="${RW_GRAPHQL_TIMEOUT:-60}" + local max_attempts="${RW_RETRY_MAX:-5}" + [ "$max_attempts" -ge 1 ] 2>/dev/null || max_attempts=1 + local delay="${RW_RETRY_DELAY:-5}" + local attempt=1 + + local payload + payload="$(jq -nc --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')" || { + printf "rw_graphql: could not build payload (bad variables JSON?)\n" >&2 + return 1 + } + + while [ "$attempt" -le "$max_attempts" ]; do + local body_file http curl_rc + body_file="$(mktemp "${TMPDIR:-/tmp}/rw-body.XXXXXX")" + printf '\n' >>"$RW_CALLS_FILE" + # `set +Ee` so a curl failure neither trips errexit nor an inherited + # ERR trap; classification happens here (same pattern as lib.sh). + http="$(set +Ee; curl -sS --connect-timeout 10 --max-time "$timeout_s" \ + -D "$RW_LAST_HEADERS_FILE" -o "$body_file" -w '%{http_code}' \ + -X POST "$endpoint" \ + -H "Authorization: Bearer ${RAILWAY_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$payload" 2>/dev/null)" && curl_rc=0 || curl_rc=$? + RW_LAST_HTTP="$http" + + if [ "$curl_rc" -eq 0 ] && [ "$http" = "200" ] \ + && ! jq -e '.errors' "$body_file" >/dev/null 2>&1; then + cat "$body_file" + rm -f "$body_file" + return 0 + fi + + local retryable=0 wait_s="$delay" + if [ "$curl_rc" -ne 0 ]; then + # Network error / timeout: possibly ambiguous for mutations. + [ "${RW_NO_TRANSIENT_RETRY:-0}" = "1" ] || retryable=1 + elif [ "$http" = "429" ]; then + # Clean rejection: always safe to retry; honor Retry-After. + retryable=1 + wait_s="$(_rw_retry_after "$RW_LAST_HEADERS_FILE" "$delay")" + elif printf '%s' "$http" | grep -qE '^5[0-9][0-9]$'; then + [ "${RW_NO_TRANSIENT_RETRY:-0}" = "1" ] || retryable=1 + fi + + if [ "$retryable" -eq 1 ] && [ "$attempt" -lt "$max_attempts" ]; then + printf "rw_graphql: transient (http=%s curl=%s), retrying in %ss (attempt %d/%d)\n" \ + "$http" "$curl_rc" "$wait_s" "$attempt" "$max_attempts" >&2 + rm -f "$body_file" + sleep "$wait_s" + delay=$((delay * 2)) + attempt=$((attempt + 1)) + continue + fi + + printf "rw_graphql: request failed (http=%s curl=%s)\n" "$http" "$curl_rc" >&2 + [ -s "$body_file" ] && rw_redact <"$body_file" >&2 && printf '\n' >&2 + rm -f "$body_file" + return 1 + done +} + +# rw_find_project_id : resolve a project id by name. `projects` with no +# arguments returns an EMPTY list for account tokens — the query must be +# scoped with workspaceId, so iterate the token's workspaces from `me`. First +# match wins (preview projects can carry duplicate names). +rw_find_project_id() { + local name="$1" ws id + local ws_ids + ws_ids="$(rw_graphql 'query { me { workspaces { id } } }' \ + | jq -r '.data.me.workspaces[].id')" || return 1 + for ws in $ws_ids; do + id="$(rw_graphql \ + 'query($w: String!) { projects(workspaceId: $w, first: 100) { edges { node { id name } } } }' \ + "$(jq -nc --arg w "$ws" '{w: $w}')" \ + | jq -r --arg n "$name" \ + '[.data.projects.edges[].node | select(.name == $n) | .id][0] // empty')" || return 1 + if [ -n "$id" ]; then + printf '%s' "$id" + return 0 + fi + done + return 1 +} + +# rw_call_count: total HTTP requests recorded by this process tree. +rw_call_count() { + if [ -f "$RW_CALLS_FILE" ]; then wc -l <"$RW_CALLS_FILE" | tr -d ' '; else printf '0'; fi +} + +# rw_report_calls [label]: report how many HTTP requests this run has made. +rw_report_calls() { + printf "%s: %s Railway API call(s) so far\n" "${1:-rw_graphql}" "$(rw_call_count)" >&2 +} + +# rw_rate_limit_headers: print the rate-limit headers from the last response +# (documented as X-RateLimit-* + Retry-After). No secrets appear in response +# headers, so this is safe to print in CI logs. +rw_rate_limit_headers() { + if [ -s "$RW_LAST_HEADERS_FILE" ]; then + grep -iE '^(x-ratelimit|retry-after)' "$RW_LAST_HEADERS_FILE" | tr -d '\r' \ + || printf "(no rate-limit headers on last response)\n" + else + printf "(no response captured yet)\n" + fi +} diff --git a/hosting/railway/oss/template/template.json b/hosting/railway/oss/template/template.json new file mode 100644 index 0000000000..6ea197ce1a --- /dev/null +++ b/hosting/railway/oss/template/template.json @@ -0,0 +1,442 @@ +{ + "version": 1, + "project": "agenta-oss-clone-spike", + "templateEnvironment": "pr-template", + "notes": { + "purpose": "Declarative definition of the Railway preview TEMPLATE environment (issue #5650). Per-PR previews are created by cloning this environment; apply.sh converges a live environment to this file and the drift workflow (47) alerts on hand-edits.", + "project-todo": "TODO(WP3-project): the rollout step points 'project' at the production preview project once WP3 lands; until then the definition targets the proven spike project.", + "variable-classes": "A variable value that contains a ${{...}} Railway reference is PER-ENVIRONMENT-UNIQUE: Railway re-resolves it inside every clone (proven, findings.md Q2). A literal string value is SHARED: byte-identical in the template and every clone. An object {\"secret\": NAME} is a SHARED SECRET: the value is never stored in this file; apply.sh resolves it at apply time from the process environment, from the value already live on a sibling service, or from the declared generator, in that order.", + "system-variables": "Railway injects RAILWAY_* variables (RAILWAY_ENVIRONMENT*, RAILWAY_PROJECT_*, RAILWAY_SERVICE_*, RAILWAY_PRIVATE_DOMAIN, ...) into every service. The diff ignores any RAILWAY_* name UNLESS it is declared here (declared ones, e.g. redis's RAILWAY_RUN_UID, are managed and diffed like any other).", + "start-command-policy": "Services whose image owns its entrypoint (Postgres, supertokens, and the three WP1 wrapper images gateway/redis/seaweedfs) MUST have startCommand empty/unset. The eight app services carry explicit startCommands because one image backs several services (agenta-api alone backs api, worker-streams, worker-queues, cron, and alembic). API fact: startCommand null in serviceInstanceUpdate is a NO-OP; \"\" clears the override (findings.md close-out addendum).", + "tag-policy": "Template image tags must NEVER equal a PR image tag (pr--) and must NEVER be 'latest': environmentPatchCommit silently no-ops when a patched tag equals the template's tag, which strands a clone on template images (findings.md, deploy-mode section). apply.sh enforces this.", + "healthchecks": "healthcheckPath is unset on all services, matching the live-proven template (10/10 green clone cycles). The legacy per-PR path (scripts/configure.sh) sets healthchecks for gateway/api/services/runner; adding them to the template is a WP3 decision, not silent drift." + }, + "parameters": { + "app_tag": { + "default": "v0.107.0", + "description": "Tag for the four Agenta app images (agenta-api, agenta-web, agenta-services, agenta-runner). Pinned to a release tag; per-PR clones patch these images to pr-- tags via environmentPatchCommit. Never 'latest', never a pr-* tag (see notes.tag-policy)." + }, + "wrapper_tag": { + "default": "spike", + "description": "TODO(WP1-tag): tag for the three preview wrapper images (agenta-preview-gateway/redis/seaweedfs). WP1 establishes content-addressed tags built by workflow 42; the WP3/rollout step pins this default to the first WP1-built tag. ':spike' is the live-proven placeholder pushed during the spike. Never 'latest', never a pr-* tag." + } + }, + "deployOrder": { + "description": "Constraint for anything that deploys a fresh clone of this environment (proven live, findings.md Q12 + close-out): infra first, then alembic (creates the three databases and runs migrations), then everything else. supertokens MUST NOT deploy before alembic (its database does not exist yet and it exhausts its restarts). A single Postgres first-deploy timeout in a fresh clone is retryable, not fatal.", + "stages": [ + [ + "Postgres", + "redis", + "seaweedfs" + ], + [ + "alembic" + ], + [ + "supertokens", + "api", + "web", + "services", + "runner", + "worker-streams", + "worker-queues", + "cron", + "gateway" + ] + ] + }, + "secrets": { + "AGENTA_AUTH_KEY": { + "from_env": "AGENTA_AUTH_KEY", + "generate": "openssl-rand-hex-32" + }, + "AGENTA_CRYPT_KEY": { + "from_env": "AGENTA_CRYPT_KEY", + "generate": "openssl-rand-hex-32" + }, + "AGENTA_RUNNER_TOKEN": { + "from_env": "AGENTA_RUNNER_TOKEN", + "generate": "openssl-rand-hex-32" + }, + "POSTGRES_PASSWORD": { + "from_env": "POSTGRES_PASSWORD", + "generate": "openssl-rand-hex-24" + }, + "AGENTA_STORE_ACCESS_KEY": { + "from_env": "AGENTA_STORE_ACCESS_KEY", + "generate": "openssl-rand-hex-10" + }, + "AGENTA_STORE_SECRET_KEY": { + "from_env": "AGENTA_STORE_SECRET_KEY", + "generate": "openssl-rand-hex-32" + }, + "AGENTA_STORE_SIGNING_KEY": { + "from_env": "AGENTA_STORE_SIGNING_KEY", + "generate": "openssl-rand-base64-32" + }, + "AGENTA_STORE_JWT_PRIVATE_KEY": { + "from_env": "AGENTA_STORE_JWT_PRIVATE_KEY", + "generate": "openssl-genpkey-rsa-2048" + } + }, + "services": { + "gateway": { + "image": "ghcr.io/agenta-ai/agenta-preview-gateway:{wrapper_tag}", + "startCommand": "", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "PORT": "8080" + } + }, + "web": { + "image": "ghcr.io/agenta-ai/agenta-web:{app_tag}", + "startCommand": "sh -lc '/app/entrypoint.sh node /app/oss/server.js'", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_API_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/api", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "AGENTA_LICENSE": "oss", + "HOSTNAME": "0.0.0.0" + }, + "optionalVariables": [ + "POSTHOG_API_KEY", + "SENDGRID_API_KEY" + ] + }, + "api": { + "image": "ghcr.io/agenta-ai/agenta-api:{app_tag}", + "startCommand": "gunicorn entrypoints.routers:app --bind 0.0.0.0:8000 --worker-class uvicorn.workers.UvicornWorker --workers 2 --max-requests 10000 --max-requests-jitter 1000 --timeout 60 --graceful-timeout 60 --log-level info --access-logfile - --error-logfile -", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_API_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/api", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_STORE_ENDPOINT_URL": "http://${{seaweedfs.RAILWAY_PRIVATE_DOMAIN}}:8333", + "AGENTA_STORE_ACCESS_KEY": { + "secret": "AGENTA_STORE_ACCESS_KEY" + }, + "AGENTA_STORE_SECRET_KEY": { + "secret": "AGENTA_STORE_SECRET_KEY" + }, + "AGENTA_STORE_BUCKET": "agenta-store", + "AGENTA_STORE_SIGNING_KEY": { + "secret": "AGENTA_STORE_SIGNING_KEY" + }, + "AGENTA_STORE_JWT_ISSUER": "http://api.railway.internal:8000/api", + "AGENTA_STORE_JWT_PRIVATE_KEY": { + "secret": "AGENTA_STORE_JWT_PRIVATE_KEY" + }, + "AGENTA_LICENSE": "oss", + "PORT": "8000", + "SCRIPT_NAME": "/api", + "REDIS_URI": "redis://redis.railway.internal:6379/0", + "REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0", + "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0", + "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567" + }, + "optionalVariables": [ + "COMPOSIO_API_KEY", + "AGENTA_STORE_NAMESPACE" + ] + }, + "services": { + "image": "ghcr.io/agenta-ai/agenta-services:{app_tag}", + "startCommand": "gunicorn entrypoints.main:app --bind 0.0.0.0:8080 --worker-class uvicorn.workers.UvicornWorker --workers 2 --max-requests 10000 --max-requests-jitter 1000 --timeout 60 --graceful-timeout 60 --log-level info --access-logfile - --error-logfile -", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_API_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/api", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_RUNNER_INTERNAL_URL": "http://${{runner.RAILWAY_PRIVATE_DOMAIN}}:8765", + "AGENTA_RUNNER_TOKEN": { + "secret": "AGENTA_RUNNER_TOKEN" + }, + "AGENTA_STORE_ENDPOINT_URL": "http://${{seaweedfs.RAILWAY_PRIVATE_DOMAIN}}:8333", + "AGENTA_STORE_ACCESS_KEY": { + "secret": "AGENTA_STORE_ACCESS_KEY" + }, + "AGENTA_STORE_SECRET_KEY": { + "secret": "AGENTA_STORE_SECRET_KEY" + }, + "AGENTA_STORE_BUCKET": "agenta-store", + "AGENTA_STORE_SIGNING_KEY": { + "secret": "AGENTA_STORE_SIGNING_KEY" + }, + "AGENTA_LICENSE": "oss", + "PORT": "8080", + "SCRIPT_NAME": "/services", + "AGENTA_API_INTERNAL_URL": "http://api.railway.internal:8000/api", + "REDIS_URI": "redis://redis.railway.internal:6379/0", + "REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0", + "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0" + }, + "optionalVariables": [ + "DAYTONA_API_KEY" + ] + }, + "runner": { + "image": "ghcr.io/agenta-ai/agenta-runner:{app_tag}", + "startCommand": "node_modules/.bin/tsx src/server.ts", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_RUNNER_HOST": "0.0.0.0", + "AGENTA_RUNNER_PORT": "8765", + "AGENTA_RUNNER_TOKEN": { + "secret": "AGENTA_RUNNER_TOKEN" + }, + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS": "local", + "AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER": "local", + "AGENTA_STORE_ENDPOINT_URL": "http://${{seaweedfs.RAILWAY_PRIVATE_DOMAIN}}:8333", + "AGENTA_STORE_ACCESS_KEY": { + "secret": "AGENTA_STORE_ACCESS_KEY" + }, + "AGENTA_STORE_SECRET_KEY": { + "secret": "AGENTA_STORE_SECRET_KEY" + }, + "AGENTA_STORE_BUCKET": "agenta-store", + "AGENTA_STORE_SIGNING_KEY": { + "secret": "AGENTA_STORE_SIGNING_KEY" + } + }, + "optionalVariables": [ + "AGENTA_API_URL", + "AGENTA_API_KEY", + "AGENTA_RUNNER_DAYTONA_API_KEY", + "AGENTA_RUNNER_DAYTONA_API_URL", + "AGENTA_RUNNER_DAYTONA_TARGET", + "AGENTA_RUNNER_DAYTONA_SNAPSHOT", + "AGENTA_RUNNER_DAYTONA_IMAGE" + ] + }, + "worker-streams": { + "image": "ghcr.io/agenta-ai/agenta-api:{app_tag}", + "startCommand": "python -m entrypoints.worker_streams", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_LICENSE": "oss", + "AGENTA_API_URL": "http://api.railway.internal:8000/api", + "AGENTA_API_INTERNAL_URL": "http://api.railway.internal:8000/api", + "REDIS_URI": "redis://redis.railway.internal:6379/0", + "REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0", + "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0", + "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567" + } + }, + "worker-queues": { + "image": "ghcr.io/agenta-ai/agenta-api:{app_tag}", + "startCommand": "python -m entrypoints.worker_queues", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_LICENSE": "oss", + "AGENTA_API_URL": "http://api.railway.internal:8000/api", + "AGENTA_API_INTERNAL_URL": "http://api.railway.internal:8000/api", + "REDIS_URI": "redis://redis.railway.internal:6379/0", + "REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0", + "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0", + "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567" + } + }, + "cron": { + "image": "ghcr.io/agenta-ai/agenta-api:{app_tag}", + "startCommand": "/usr/local/bin/supercronic /app/crontab", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_LICENSE": "oss", + "AGENTA_API_URL": "http://api.railway.internal:8000/api", + "AGENTA_API_INTERNAL_URL": "http://api.railway.internal:8000/api", + "REDIS_URI": "redis://redis.railway.internal:6379/0", + "REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0", + "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0", + "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567" + } + }, + "alembic": { + "image": "ghcr.io/agenta-ai/agenta-api:{app_tag}", + "startCommand": "sh -c 'until psql -tAc \"SELECT 1\" >/dev/null 2>&1; do sleep 2; done; for db in agenta_oss_core agenta_oss_tracing agenta_oss_supertokens; do psql -tAc \"SELECT 1 FROM pg_database WHERE datname = '\\''$db'\\''\" | grep -q 1 || psql -c \"CREATE DATABASE \\\"$db\\\"\"; done; exec /opt/venv/bin/python -m oss.databases.postgres.migrations.runner'", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "AGENTA_WEB_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}", + "AGENTA_API_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/api", + "AGENTA_SERVICES_URL": "https://${{gateway.RAILWAY_PUBLIC_DOMAIN}}/services", + "AGENTA_AUTH_KEY": { + "secret": "AGENTA_AUTH_KEY" + }, + "AGENTA_CRYPT_KEY": { + "secret": "AGENTA_CRYPT_KEY" + }, + "POSTGRES_URI_CORE": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_core", + "POSTGRES_URI_TRACING": "postgresql+asyncpg://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_tracing", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "AGENTA_LICENSE": "oss", + "ALEMBIC_CFG_PATH_CORE": "/app/oss/databases/postgres/migrations/core/alembic.ini", + "ALEMBIC_CFG_PATH_TRACING": "/app/oss/databases/postgres/migrations/tracing/alembic.ini", + "PGHOST": "${{Postgres.RAILWAY_PRIVATE_DOMAIN}}", + "PGPORT": "${{Postgres.PGPORT}}", + "PGUSER": "${{Postgres.POSTGRES_USER}}", + "PGPASSWORD": "${{Postgres.POSTGRES_PASSWORD}}", + "PGDATABASE": "postgres" + } + }, + "supertokens": { + "image": "supertokens/supertokens-postgresql:11", + "startCommand": "", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [], + "variables": { + "POSTGRESQL_CONNECTION_URI": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens", + "POSTGRES_URI_SUPERTOKENS": "postgresql://${{Postgres.POSTGRES_USER}}:${{Postgres.POSTGRES_PASSWORD}}@${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:${{Postgres.PGPORT}}/agenta_oss_supertokens" + } + }, + "Postgres": { + "image": "postgres:17", + "startCommand": "", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [ + "/var/lib/postgresql/data" + ], + "variables": { + "PGDATA": "/var/lib/postgresql/data/pgdata", + "PGHOST": "postgres.railway.internal", + "PGDATABASE": "railway", + "PGUSER": "postgres", + "PGPASSWORD": { + "secret": "POSTGRES_PASSWORD" + }, + "PGPORT": "5432", + "POSTGRES_DB": "railway", + "POSTGRES_USER": "postgres", + "POSTGRES_PASSWORD": { + "secret": "POSTGRES_PASSWORD" + }, + "SSL_CERT_DAYS": "820", + "RAILWAY_DEPLOYMENT_DRAINING_SECONDS": "60" + } + }, + "redis": { + "image": "ghcr.io/agenta-ai/agenta-preview-redis:{wrapper_tag}", + "startCommand": "", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [ + "/data" + ], + "variables": { + "RAILWAY_RUN_UID": "0", + "RAILWAY_RUN_GID": "0" + } + }, + "seaweedfs": { + "image": "ghcr.io/agenta-ai/agenta-preview-seaweedfs:{wrapper_tag}", + "startCommand": "", + "healthcheckPath": null, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "volumes": [ + "/data" + ], + "variables": { + "AGENTA_STORE_ACCESS_KEY": { + "secret": "AGENTA_STORE_ACCESS_KEY" + }, + "AGENTA_STORE_SECRET_KEY": { + "secret": "AGENTA_STORE_SECRET_KEY" + }, + "AGENTA_STORE_BUCKET": "agenta-store", + "AGENTA_STORE_SIGNING_KEY": { + "secret": "AGENTA_STORE_SIGNING_KEY" + }, + "AGENTA_STORE_JWT_ISSUER": "http://api.railway.internal:8000/api" + } + } + } +}