diff --git a/.github/workflows/42-railway-build.yml b/.github/workflows/42-railway-build.yml index 97519168d6..5d038b51c3 100644 --- a/.github/workflows/42-railway-build.yml +++ b/.github/workflows/42-railway-build.yml @@ -207,6 +207,94 @@ jobs: run: | echo "- Built \`${{ matrix.image_name }}:${{ needs.prepare.outputs.image_tag }}-${{ matrix.arch }}\`" >> "$GITHUB_STEP_SUMMARY" + # Prebuilt Railway wrapper images (gateway, redis, seaweedfs) for + # clone-based preview environments (issue #5650). Content-addressed tags + # only — `compute-tag.sh` hashes the image directory, so unchanged content + # reuses the manifest already in GHCR and nothing is rebuilt. Never `latest`: + # Railway's environmentPatchCommit no-ops when the patched tag equals the + # template's, so tags must stay unique and disjoint. Each run also aliases + # the content manifest with the run's image tag (`pr--` / + # `manual-`) to match the app-image tagging pattern. + wrapper-images: + name: wrapper-image + needs: prepare + # Wrapper pushes need registry write access; fork PRs run with a read-only + # GITHUB_TOKEN, so skip them cleanly there. + if: >- + needs.prepare.outputs.build_images == 'true' && + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image_name: + - gateway + - redis + - seaweedfs + steps: + - uses: actions/checkout@v6 + + # Drift between the legacy deploy-time wrapper generation + # (deploy-from-images.sh / deploy-gateway.sh) and these image sources + # fails the build before anything is pushed. + - name: Verify wrapper sources match deploy-time generation + run: bash hosting/railway/oss/images/verify-wrappers.sh + + - name: Compute content tag + id: tag + run: | + CONTENT_TAG="$(bash hosting/railway/oss/images/compute-tag.sh "hosting/railway/oss/images/${{ matrix.image_name }}")" + echo "content_tag=${CONTENT_TAG}" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Check whether the content tag already exists + id: existing + env: + IMAGE: ghcr.io/agenta-ai/agenta-preview-${{ matrix.image_name }} + TAG: ${{ steps.tag.outputs.content_tag }} + run: | + if docker manifest inspect "${IMAGE}:${TAG}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Manifest ${IMAGE}:${TAG} already exists; skipping build" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push wrapper image + if: steps.existing.outputs.exists != 'true' + uses: docker/build-push-action@v6 + with: + context: hosting/railway/oss/images/${{ matrix.image_name }} + push: true + platforms: linux/amd64 + # Same rationale as the app images: no provenance/SBOM attestations + # for preview images. + provenance: false + sbom: false + tags: ghcr.io/agenta-ai/agenta-preview-${{ matrix.image_name }}:${{ steps.tag.outputs.content_tag }} + + - name: Tag per-build alias + env: + IMAGE: ghcr.io/agenta-ai/agenta-preview-${{ matrix.image_name }} + CONTENT_TAG: ${{ steps.tag.outputs.content_tag }} + ALIAS_TAG: ${{ needs.prepare.outputs.image_tag }} + run: | + docker buildx imagetools create -t "${IMAGE}:${ALIAS_TAG}" "${IMAGE}:${CONTENT_TAG}" + + - name: Summary + run: | + echo "- Wrapper \`agenta-preview-${{ matrix.image_name }}:${{ steps.tag.outputs.content_tag }}\` (alias \`${{ needs.prepare.outputs.image_tag }}\`; already existed: \`${{ steps.existing.outputs.exists }}\`)" >> "$GITHUB_STEP_SUMMARY" + merge-manifests: name: merge-manifest needs: [prepare, build-and-push] diff --git a/hosting/railway/oss/README.md b/hosting/railway/oss/README.md index 6f28427773..48fcbcea2c 100644 --- a/hosting/railway/oss/README.md +++ b/hosting/railway/oss/README.md @@ -29,6 +29,7 @@ baseline. - `worker-queues/` - list-parameterized worker image for taskiq queue consumers (webhooks, triggers, interactions, evaluations) - `cron/` - cron service image - `alembic/` - migration runner image +- `images/` - prebuilt wrapper image sources (`gateway`, `redis`, `seaweedfs`) pushed to GHCR for clone-based previews - `scripts/bootstrap.sh` - create project, environment, and services - `scripts/configure.sh` - set variables and start commands - `scripts/deploy-gateway.sh` - deploy gateway image from local Dockerfile @@ -149,6 +150,46 @@ Defaults: - Project naming uses `RAILWAY_PREVIEW_PROJECT_PREFIX` (default `agenta-oss-pr`) and a normalized preview key. - Preview key resolution order is `RAILWAY_PREVIEW_KEY`, `PR_NUMBER`, `GITHUB_PR_NUMBER`, then GitHub branch refs. +## Prebuilt Wrapper Images (Clone-Based Previews) + +Clone-based preview environments (issue #5650) consume `gateway`, `redis`, and +`seaweedfs` as plain registry images instead of `railway up` uploads, because +upload-built sources do not survive Railway environment cloning. + +- Sources live in `images/{gateway,redis,seaweedfs}/` (Dockerfile plus config + or entrypoint files). They must stay byte-faithful to what the legacy deploy + path ships per PR today: the `render_redis_wrapper()` and + `render_seaweedfs_wrapper()` heredocs in `scripts/deploy-from-images.sh`, + and the `gateway/` directory that `scripts/deploy-gateway.sh` uploads. +- `images/verify-wrappers.sh` enforces that byte-faithfulness (it regenerates + the deploy-time content from `deploy-from-images.sh` itself) and runs as the + first step of the CI job, so drift between the two paths fails the build. + If the compose baseline moves the Redis image, or the `SEAWEEDFS_IMAGE` + default changes, bump the matching `FROM` pin in `images/*/Dockerfile`. +- CI (the `wrapper-images` job in `.github/workflows/42-railway-build.yml`) + builds `ghcr.io/agenta-ai/agenta-preview-{gateway,redis,seaweedfs}` for + `linux/amd64`. +- Tags are content-addressed, never `latest`: `images/compute-tag.sh ` + prints `content-<12 hex>` from a deterministic hash of the directory + content. Unchanged content maps to a tag that already exists in GHCR, so CI + skips the rebuild. Each run also aliases the content manifest with the run's + image tag (`pr--` or `manual-`). Railway's + `environmentPatchCommit` no-ops when a patched tag equals the template's, + which is why unique, disjoint tags matter. + +Build locally: + +```bash +./hosting/railway/oss/images/verify-wrappers.sh + +TAG="$(./hosting/railway/oss/images/compute-tag.sh hosting/railway/oss/images/gateway)" +docker build -t "ghcr.io/agenta-ai/agenta-preview-gateway:${TAG}" hosting/railway/oss/images/gateway +``` + +The first CI push creates each `agenta-preview-*` GHCR package as private; +make it public once (like the other preview packages) if clone environments +should pull without registry credentials. + ## Template Export Readiness Railway template generation requires every service to have source metadata. diff --git a/hosting/railway/oss/images/compute-tag.sh b/hosting/railway/oss/images/compute-tag.sh new file mode 100755 index 0000000000..808ae822a6 --- /dev/null +++ b/hosting/railway/oss/images/compute-tag.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Print the content-addressed image tag for a wrapper image directory. +# +# The tag is `content-<12 hex chars>`: the sha256 over every file's path and +# sha256 (paths sorted with LC_ALL=C, so the result is deterministic across +# machines and runs — no timestamps, no file modes, no tar metadata). +# Unchanged directory content always yields the same tag, which is how CI +# decides that a wrapper image is already in the registry and skips the build. +# +# Usage: compute-tag.sh +# Example: compute-tag.sh hosting/railway/oss/images/gateway +# +# Not covered by the hash (irrelevant to the built image): file modes +# (entrypoints are chmod'ed inside the Dockerfiles) and empty directories. + +set -euo pipefail + +dir="${1:?usage: compute-tag.sh }" + +if [ ! -d "$dir" ]; then + printf "Not a directory: %s\n" "$dir" >&2 + exit 1 +fi + +cd "$dir" + +hash="$(find . -type f -print0 | LC_ALL=C sort -z | xargs -0 -r sha256sum | sha256sum | cut -c1-12)" + +printf 'content-%s\n' "$hash" diff --git a/hosting/railway/oss/images/gateway/Dockerfile b/hosting/railway/oss/images/gateway/Dockerfile new file mode 100644 index 0000000000..fc8793262e --- /dev/null +++ b/hosting/railway/oss/images/gateway/Dockerfile @@ -0,0 +1,19 @@ +# Prebuilt gateway wrapper image for Railway preview environments (issue #5650). +# +# Byte-faithful to hosting/railway/oss/gateway/Dockerfile (the source that +# `railway up gateway` builds today via deploy-gateway.sh); nginx.conf beside +# this file is a verbatim copy of hosting/railway/oss/gateway/nginx.conf +# (Railway private-DNS resolver [fd12::10] with variable-based proxy_pass so +# upstream redeploys re-resolve). +# Deliberate divergences from the deploy-time content: none — only these header +# comments were added. CI runs images/verify-wrappers.sh and fails on any drift. +# +# Runtime contract: expose the nginx listen port via the PORT env var +# (default 8080; the template sets PORT=8080 and the service domain targets it). +FROM nginx:1.27-alpine + +ENV PORT=8080 + +COPY nginx.conf /etc/nginx/nginx.conf.template + +CMD ["/bin/sh", "-c", "envsubst '${PORT}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"] diff --git a/hosting/railway/oss/images/gateway/nginx.conf b/hosting/railway/oss/images/gateway/nginx.conf new file mode 100644 index 0000000000..1223dd0efa --- /dev/null +++ b/hosting/railway/oss/images/gateway/nginx.conf @@ -0,0 +1,65 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Railway private networking DNS resolver (IPv6). + # Using variable-based proxy_pass forces Nginx to re-resolve DNS on every + # request via this resolver, instead of caching the IP from startup forever. + # Without this, any upstream service redeploy changes its internal IP and + # Nginx keeps connecting to the dead old IP, causing 504 gateway timeouts. + # + # Note: variable-based proxy_pass does not do automatic URI stripping like + # literal proxy_pass with a trailing slash. We use rewrite to strip path + # prefixes explicitly. + resolver [fd12::10] valid=5s ipv6=off; + + server { + listen ${PORT}; + + client_max_body_size 32m; + + # Timeouts for private network connections + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + + # Session refresh can emit a large Set-Cookie header. Increase upstream + # proxy buffers so auth responses do not overflow Nginx defaults. + proxy_buffer_size 16k; + proxy_buffers 4 16k; + proxy_busy_buffers_size 16k; + + location /api/ { + set $api_upstream "api.railway.internal:8000"; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://$api_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /services/ { + set $services_upstream "services.railway.internal:8080"; + rewrite ^/services/(.*)$ /$1 break; + proxy_pass http://$services_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + set $web_upstream "web.railway.internal:8080"; + proxy_pass http://$web_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/hosting/railway/oss/images/redis/Dockerfile b/hosting/railway/oss/images/redis/Dockerfile new file mode 100644 index 0000000000..18cd6133c7 --- /dev/null +++ b/hosting/railway/oss/images/redis/Dockerfile @@ -0,0 +1,23 @@ +# Prebuilt redis wrapper image for Railway preview environments (issue #5650). +# +# Byte-faithful to the Dockerfile render_redis_wrapper() in +# hosting/railway/oss/scripts/deploy-from-images.sh generates per PR today; +# entrypoint.sh beside this file is a verbatim copy of +# hosting/railway/oss/redis/entrypoint.sh (chowns /data to the redis UID before +# delegating to the official docker-entrypoint.sh — failure-history item 14: +# Railway volumes mount root-owned and plain redis:8 crash-loops on MISCONF). +# Deliberate divergences from the deploy-time content: +# - the FROM tag is hard-pinned to redis:8 instead of resolved at render time +# from hosting/docker-compose/oss/docker-compose.gh.yml (same value today). +# Only that pin and these header comments differ. CI runs +# images/verify-wrappers.sh, which re-resolves the compose baseline and fails +# the build if this pin (or anything else) drifts. +FROM redis:8 + +COPY entrypoint.sh /usr/local/bin/railway-redis-entrypoint.sh +RUN chmod +x /usr/local/bin/railway-redis-entrypoint.sh + +USER root + +ENTRYPOINT ["/usr/local/bin/railway-redis-entrypoint.sh"] +CMD ["redis-server"] diff --git a/hosting/railway/oss/images/redis/entrypoint.sh b/hosting/railway/oss/images/redis/entrypoint.sh new file mode 100644 index 0000000000..8791701652 --- /dev/null +++ b/hosting/railway/oss/images/redis/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -eu + +# Ensure the persistent data dir exists and is writable before delegating +# to Redis' official entrypoint (which will drop privileges as needed). +mkdir -p /data +REDIS_UID="$(id -u redis)" +REDIS_GID="$(id -g redis)" +DATA_OWNER="$(stat -c '%u:%g' /data 2>/dev/null || true)" + +if [ "$DATA_OWNER" != "${REDIS_UID}:${REDIS_GID}" ]; then + chown -R redis:redis /data +fi + +exec /usr/local/bin/docker-entrypoint.sh "$@" diff --git a/hosting/railway/oss/images/seaweedfs/Dockerfile b/hosting/railway/oss/images/seaweedfs/Dockerfile new file mode 100644 index 0000000000..d8098add73 --- /dev/null +++ b/hosting/railway/oss/images/seaweedfs/Dockerfile @@ -0,0 +1,23 @@ +# Prebuilt SeaweedFS wrapper image for Railway preview environments (issue #5650). +# +# Byte-faithful to the Dockerfile render_seaweedfs_wrapper() in +# hosting/railway/oss/scripts/deploy-from-images.sh generates per PR today; +# entrypoint.sh beside this file is a verbatim copy of +# hosting/railway/oss/seaweedfs/entrypoint.sh (renders s3.json + the advanced +# IAM config from env, then starts `weed server` with the IAM engine — +# failure-history item 17: without it the STS AssumeRoleWithWebIdentity path +# the store mounts need does not exist). +# Base pinned to 4.37: its advanced IAM regressed in other releases (same pin +# as SEAWEEDFS_IMAGE in deploy-from-images.sh). +# Deliberate divergences from the deploy-time content: none — only these header +# comments were added. CI runs images/verify-wrappers.sh and fails on any drift. +# +# Runtime contract (env the entrypoint requires): AGENTA_STORE_ACCESS_KEY, +# AGENTA_STORE_SECRET_KEY, AGENTA_STORE_SIGNING_KEY, AGENTA_STORE_JWT_ISSUER, +# AGENTA_STORE_BUCKET (all set by configure.sh in the template environment). +FROM chrislusf/seaweedfs:4.37 + +COPY entrypoint.sh /usr/local/bin/railway-seaweedfs-entrypoint.sh +RUN chmod +x /usr/local/bin/railway-seaweedfs-entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/railway-seaweedfs-entrypoint.sh"] diff --git a/hosting/railway/oss/images/seaweedfs/entrypoint.sh b/hosting/railway/oss/images/seaweedfs/entrypoint.sh new file mode 100644 index 0000000000..1e80dd207e --- /dev/null +++ b/hosting/railway/oss/images/seaweedfs/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +set -eu + +# Railway SeaweedFS entrypoint. Generates the master s3.json AND the advanced IAM +# config (iam.json) from env, then starts the gateway with the IAM engine — the only +# STS path SeaweedFS authorizes (AssumeRoleWithWebIdentity against the API's JWKS). +# Mirrors the docker-compose / Helm bundled-store config. GetFederationToken is NOT +# modelled by SeaweedFS IAM (yields actionless tokens), so the OIDC provider is required. + +mkdir -p /etc/seaweedfs /data + +cat > /etc/seaweedfs/s3.json < /etc/seaweedfs/iam.json < ... <&2 + failures=$((failures + 1)) + fi +} + +check_bytes() { + local label="$1" expected="$2" actual="$3" + if ! diff -u --label "$expected" --label "$actual" "$expected" "$actual"; then + printf "FAIL: %s is not a verbatim copy of %s\n\n" "$actual" "$expected" >&2 + failures=$((failures + 1)) + fi +} + +# --- Resolve the FROM pins exactly like deploy-from-images.sh defaults do --- + +SOURCE_COMPOSE_FILE="$(railway_source_compose_file "$ROOT_DIR")" +REDIS_IMAGE="$(require_compose_redis_image "$SOURCE_COMPOSE_FILE")" +# shellcheck disable=SC2016 # the pattern matches a literal ${...} in the deploy script +SEAWEEDFS_IMAGE="$(sed -n 's/^SEAWEEDFS_IMAGE="\${SEAWEEDFS_IMAGE:-\(.*\)}"$/\1/p' "$DEPLOY_SCRIPT")" + +if [ -z "$SEAWEEDFS_IMAGE" ]; then + printf "Could not extract the SEAWEEDFS_IMAGE default from %s\n" "$DEPLOY_SCRIPT" >&2 + exit 1 +fi + +export REDIS_IMAGE SEAWEEDFS_IMAGE + +# --- gateway: railway up ships hosting/railway/oss/gateway as-is ------------ + +strip_header "$OSS_DIR/gateway/Dockerfile" > "$TMP_DIR/gateway.expected" +strip_header "$IMAGES_DIR/gateway/Dockerfile" > "$TMP_DIR/gateway.actual" +check_diff "gateway Dockerfile" "$TMP_DIR/gateway.expected" "$TMP_DIR/gateway.actual" +check_bytes "gateway nginx.conf" "$OSS_DIR/gateway/nginx.conf" "$IMAGES_DIR/gateway/nginx.conf" + +# --- redis: render_redis_wrapper() + verbatim entrypoint -------------------- + +redis_body="$(extract_heredoc render_redis_wrapper)" +if [ -z "$redis_body" ]; then + printf "Could not extract the render_redis_wrapper heredoc from %s\n" "$DEPLOY_SCRIPT" >&2 + exit 1 +fi +expand_template "$redis_body" > "$TMP_DIR/redis.expected" +strip_header "$IMAGES_DIR/redis/Dockerfile" > "$TMP_DIR/redis.actual" +check_diff "redis Dockerfile" "$TMP_DIR/redis.expected" "$TMP_DIR/redis.actual" +check_bytes "redis entrypoint" "$OSS_DIR/redis/entrypoint.sh" "$IMAGES_DIR/redis/entrypoint.sh" + +# --- seaweedfs: render_seaweedfs_wrapper() + verbatim entrypoint ------------ + +seaweedfs_body="$(extract_heredoc render_seaweedfs_wrapper)" +if [ -z "$seaweedfs_body" ]; then + printf "Could not extract the render_seaweedfs_wrapper heredoc from %s\n" "$DEPLOY_SCRIPT" >&2 + exit 1 +fi +expand_template "$seaweedfs_body" > "$TMP_DIR/seaweedfs.expected" +strip_header "$IMAGES_DIR/seaweedfs/Dockerfile" > "$TMP_DIR/seaweedfs.actual" +check_diff "seaweedfs Dockerfile" "$TMP_DIR/seaweedfs.expected" "$TMP_DIR/seaweedfs.actual" +check_bytes "seaweedfs entrypoint" "$OSS_DIR/seaweedfs/entrypoint.sh" "$IMAGES_DIR/seaweedfs/entrypoint.sh" + +# ---------------------------------------------------------------------------- + +if [ "$failures" -gt 0 ]; then + printf "verify-wrappers: %s check(s) failed\n" "$failures" >&2 + exit 1 +fi + +printf "verify-wrappers: all wrapper image sources match the deploy-time content\n" +printf " redis FROM pin: %s\n" "$REDIS_IMAGE" +printf " seaweedfs FROM pin: %s\n" "$SEAWEEDFS_IMAGE"