Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/42-railway-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>-<sha>` /
# `manual-<sha>`) 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]
Expand Down
41 changes: 41 additions & 0 deletions hosting/railway/oss/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <dir>`
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-<number>-<sha>` or `manual-<sha>`). 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.
Expand Down
30 changes: 30 additions & 0 deletions hosting/railway/oss/images/compute-tag.sh
Original file line number Diff line number Diff line change
@@ -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 <image-dir>
# 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 <image-dir>}"

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"
19 changes: 19 additions & 0 deletions hosting/railway/oss/images/gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -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;'"]
65 changes: 65 additions & 0 deletions hosting/railway/oss/images/gateway/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
23 changes: 23 additions & 0 deletions hosting/railway/oss/images/redis/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
16 changes: 16 additions & 0 deletions hosting/railway/oss/images/redis/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
23 changes: 23 additions & 0 deletions hosting/railway/oss/images/seaweedfs/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
21 changes: 21 additions & 0 deletions hosting/railway/oss/images/seaweedfs/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
{"identities":[{"name":"agenta","credentials":[{"accessKey":"${AGENTA_STORE_ACCESS_KEY}","secretKey":"${AGENTA_STORE_SECRET_KEY}"}],"actions":["Admin","Read","Write","List","Tagging"]}]}
EOF

cat > /etc/seaweedfs/iam.json <<EOF
{"sts":{"tokenDuration":"1h","maxSessionLength":"12h","issuer":"seaweedfs-sts","signingKey":"${AGENTA_STORE_SIGNING_KEY}"},"providers":[{"name":"agenta","type":"oidc","enabled":true,"config":{"issuer":"${AGENTA_STORE_JWT_ISSUER}","clientId":"agenta-store","jwksUri":"${AGENTA_STORE_JWT_ISSUER}/.well-known/jwks.json"}}],"policies":[{"name":"store-rw","document":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::${AGENTA_STORE_BUCKET}","arn:aws:s3:::${AGENTA_STORE_BUCKET}/*"]}]}}],"roles":[{"roleName":"agenta-store","roleArn":"arn:aws:iam::role/agenta-store","attachedPolicies":["store-rw"],"trustPolicy":{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"agenta"},"Action":["sts:AssumeRoleWithWebIdentity"]}]}}]}
EOF

exec weed server -dir=/data -ip="$(hostname -i)" -volume.max=64 -s3 -s3.port=8333 -s3.config=/etc/seaweedfs/s3.json -s3.iam.config=/etc/seaweedfs/iam.json
Loading
Loading