From 552fb4d7908ba0eb82967274ebe26633c7b9824a Mon Sep 17 00:00:00 2001 From: aravindgee Date: Tue, 11 Aug 2026 17:45:29 -0400 Subject: [PATCH 1/2] Add ECR build/deploy pipeline scaffolding for EKS migration SREP-2737: draft the GitOps side of the Render -> EKS deployment pipeline, modeled on doorway-kms's build-push-ecr.yml/verify-staging-image.yml and 3-source ArgoCD values pattern. Reuses the existing Dockerfile as-is; adds a new ECR-targeting workflow alongside (not replacing) the existing GHCR build.yaml, since that's still wired into the upstream Alto NPM-publish flow. Several config values are TODO placeholders pending confirmation from the team (hostnames, chain config, ElastiCache endpoints). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/build-push-ecr.yml | 110 +++++++++++++++++++++ .github/workflows/verify-staging-image.yml | 103 +++++++++++++++++++ deploy/common.values.yaml | 97 ++++++++++++++++++ deploy/prod.values.yaml | 47 +++++++++ deploy/staging.values.yaml | 40 ++++++++ 5 files changed, 397 insertions(+) create mode 100644 .github/workflows/build-push-ecr.yml create mode 100644 .github/workflows/verify-staging-image.yml create mode 100644 deploy/common.values.yaml create mode 100644 deploy/prod.values.yaml create mode 100644 deploy/staging.values.yaml diff --git a/.github/workflows/build-push-ecr.yml b/.github/workflows/build-push-ecr.yml new file mode 100644 index 00000000..4a02a437 --- /dev/null +++ b/.github/workflows/build-push-ecr.yml @@ -0,0 +1,110 @@ +name: Build and Push to AWS ECR + +on: + pull_request: + types: [ closed ] + branches: [ main ] + paths: + - 'src/**' + - 'contracts/**' + - 'Dockerfile' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + workflow_dispatch: + +env: + AWS_REGION: us-west-2 + ECR_REPOSITORY: offchain-labs/ultra-relay + +permissions: + contents: write + id-token: write + +jobs: + build-and-push: + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + runs-on: ocl-2cpu-8ram-dind + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + submodules: recursive + persist-credentials: false + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + role-session-name: GitHubActions-${{ github.run_id }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 + with: + images: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:buildcache + cache-to: type=registry,ref=${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true + + - name: Generate release notes + id: release-notes + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + + RELEASE_NOTES=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges | head -20) + + if [ -z "$RELEASE_NOTES" ]; then + RELEASE_NOTES="- Initial release" + fi + + echo "notes<> $GITHUB_OUTPUT + echo "## Changes" >> $GITHUB_OUTPUT + echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "## Docker Images" >> $GITHUB_OUTPUT + echo "- \`${REGISTRY}/${ECR_REPOSITORY}:latest\`" >> $GITHUB_OUTPUT + echo "- \`${REGISTRY}/${ECR_REPOSITORY}:main-${GITHUB_SHA}\`" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub release + if: github.event_name != 'workflow_dispatch' + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + RELEASE_TAG: v${{ github.run_number }} + RELEASE_NOTES: ${{ steps.release-notes.outputs.notes }} + with: + script: | + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: process.env.RELEASE_TAG, + name: `Release ${process.env.RELEASE_TAG}`, + body: process.env.RELEASE_NOTES, + draft: false, + prerelease: false, + }); diff --git a/.github/workflows/verify-staging-image.yml b/.github/workflows/verify-staging-image.yml new file mode 100644 index 00000000..ea55c130 --- /dev/null +++ b/.github/workflows/verify-staging-image.yml @@ -0,0 +1,103 @@ +name: Verify staging image exists in ECR + +# Guardrail for "Release staging" PRs. +# +# The deploy tag in deploy/staging.values.yaml must point at an image that +# actually exists in ECR. build-push-ecr.yml only builds an image when a +# merged PR touches a build-relevant path (src/**, contracts/**, Dockerfile, +# package.json, pnpm-lock.yaml, pnpm-workspace.yaml). A tag whose source PR +# changed only other paths (test/**, docs/**, README) is never built, so +# pointing staging at it makes the rollout fail with ImagePullBackOff (image +# "not found"). This check fails such a PR before it can merge and reach the +# cluster. +# +# Make this a *required* status check in branch protection so it actually +# blocks merge. It runs on every PR but no-ops (success) unless staging +# values changed, so it is safe to require -- a paths: filter would instead +# leave unrelated PRs hanging on a required check that never reports. +# +# To extend the gate to other environments, add their values files to the +# git diff in "Detect changed staging values" (e.g. deploy/prod.values.yaml). + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + id-token: write + +env: + AWS_REGION: us-west-2 + ECR_REPOSITORY: offchain-labs/ultra-relay + +jobs: + verify-image-tag: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Detect changed staging values + id: changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- \ + deploy/staging.values.yaml) + if [ -z "$files" ]; then + echo "changed=" >> "$GITHUB_OUTPUT" + echo "No staging values changed by this PR; image-tag check is a no-op." + else + echo "changed=$(echo "$files" | tr '\n' ' ')" >> "$GITHUB_OUTPUT" + echo "Changed staging values:" + echo "$files" + fi + + - name: Configure AWS credentials + if: steps.changed.outputs.changed != '' + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + role-session-name: GitHubActions-verify-image-${{ github.run_id }} + aws-region: ${{ env.AWS_REGION }} + + - name: Verify referenced image tags exist in ECR + if: steps.changed.outputs.changed != '' + env: + CHANGED_FILES: ${{ steps.changed.outputs.changed }} + run: | + set -euo pipefail + + mapfile -t tags < <( + for f in $CHANGED_FILES; do + yq '.. | select(tag == "!!map") | select(has("repository") and has("tag")) | select(.repository | test("'"$ECR_REPOSITORY"'$")) | .tag' "$f" + done | grep -Ev '^(null|)$' | sort -u + ) + + if [ "${#tags[@]}" -eq 0 ]; then + echo "Changed staging values reference no ${ECR_REPOSITORY} image tag; nothing to verify." + exit 0 + fi + + status=0 + for tag in "${tags[@]}"; do + if err=$(aws ecr describe-images \ + --repository-name "$ECR_REPOSITORY" \ + --image-ids imageTag="$tag" \ + --region "$AWS_REGION" 2>&1 >/dev/null); then + echo "ok: $ECR_REPOSITORY:$tag exists in ECR" + elif printf '%s' "$err" | grep -q 'ImageNotFoundException'; then + echo "::error::$ECR_REPOSITORY:$tag is NOT in ECR. build-push-ecr only builds images for PRs touching src/**, contracts/**, Dockerfile, package.json, pnpm-lock.yaml or pnpm-workspace.yaml -- a tag whose source PR changed only other paths (e.g. test/**, docs/**) is never built. Re-point the tag to a commit that has a confirmed image (an existing ECR tag or a published Release)." + status=1 + else + echo "::error::Failed to query ECR for $ECR_REPOSITORY:$tag: $err" + status=1 + fi + done + exit $status diff --git a/deploy/common.values.yaml b/deploy/common.values.yaml new file mode 100644 index 00000000..5b0c214e --- /dev/null +++ b/deploy/common.values.yaml @@ -0,0 +1,97 @@ +application: + applicationName: ultra-relay + + labels: + app: ultra-relay + component: ultra-relay + + serviceMonitor: + enabled: true + endpoints: + - interval: 15s + path: /metrics + port: http + + rbac: + enabled: true + serviceAccount: + enabled: true + name: ultra-relay + + deployment: + enabled: true + reloadOnChange: true + configMapChecksum: true + serviceAccountName: ultra-relay + # image.repository (ECR registry, references the OCL AWS account) is set from + # zerodev-helm-charts' private $secrets values, not committed in this public repo. + # Dockerfile never sets a non-root USER, so the chart's default runAsNonRoot/readOnlyRootFilesystem would fail admission. + containerSecurityContext: + readOnlyRootFilesystem: false + runAsNonRoot: false + # No env-var binding for executor/utility private keys upstream; shell wrapper expands envFrom into flags. + command: + - /bin/sh + - -c + args: + - >- + exec pnpm start run + --config /configs/alto-config.json + --executor-private-keys "$EXECUTOR_PRIVATE_KEYS" + --utility-private-key "$UTILITY_PRIVATE_KEY" + envFrom: + ultra-relay-secrets: + type: secret + name: ultra-relay-secrets + ports: + - containerPort: 3000 + name: http + protocol: TCP + # Long grace period so in-flight mempool restoration (CONTEXT.md) finishes before SIGKILL. + terminationGracePeriodSeconds: 60 + livenessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + enabled: true + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + config: + mountPath: /configs/alto-config.json + subPath: alto-config.json + volumes: + config: + configMap: + name: ultra-relay-config + resources: + limits: + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: ultra-relay + + service: + enabled: true + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP + name: http + + configMap: + enabled: true diff --git a/deploy/prod.values.yaml b/deploy/prod.values.yaml new file mode 100644 index 00000000..e1a40dd4 --- /dev/null +++ b/deploy/prod.values.yaml @@ -0,0 +1,47 @@ +application: + deployment: + image: + tag: CHANGEME + + resources: + limits: + memory: 1Gi + requests: + cpu: 500m + memory: 512Mi + + configMap: + files: + config: + # TODO(zerodev/sre): placeholder chain config, not live values. + alto-config.json: | + { + "network-name": "CHANGEME-prod", + "log-environment": "production", + "entrypoints": "CHANGEME", + "rpc-url": "CHANGEME", + "port": 3000, + "safe-mode": true, + "enable-horizontal-scaling": true, + "enable-redis-receipt-cache": true, + "redis-endpoint": "CHANGEME-prod-ultra-relay-elasticache-endpoint", + "redis-key-prefix": "alto-prod" + } + + ingress: + enabled: true + ingressClassName: "alb" + annotations: + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]' + alb.ingress.kubernetes.io/ssl-redirect: '443' + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: "ip" + alb.ingress.kubernetes.io/healthcheck-port: '3000' + alb.ingress.kubernetes.io/healthcheck-path: '/health' + alb.ingress.kubernetes.io/backend-protocol: HTTP + hosts: + - host: aa.zerodev.app # CHANGEME - confirm with team + paths: + - path: / + pathType: Prefix + servicePort: http diff --git a/deploy/staging.values.yaml b/deploy/staging.values.yaml new file mode 100644 index 00000000..4a8b99ed --- /dev/null +++ b/deploy/staging.values.yaml @@ -0,0 +1,40 @@ +application: + deployment: + image: + tag: CHANGEME # bumped after build-push-ecr.yml publishes a tag for this commit + + configMap: + files: + config: + # TODO(zerodev/sre): placeholder chain config, not live values. + alto-config.json: | + { + "network-name": "CHANGEME-staging", + "log-environment": "production", + "entrypoints": "CHANGEME", + "rpc-url": "CHANGEME", + "port": 3000, + "safe-mode": true, + "enable-horizontal-scaling": true, + "enable-redis-receipt-cache": true, + "redis-endpoint": "CHANGEME-staging-ultra-relay-elasticache-endpoint", + "redis-key-prefix": "alto-staging" + } + + ingress: + enabled: true + ingressClassName: "alb" + annotations: + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]' + alb.ingress.kubernetes.io/ssl-redirect: '443' + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: "ip" + alb.ingress.kubernetes.io/healthcheck-port: '3000' + alb.ingress.kubernetes.io/healthcheck-path: '/health' + alb.ingress.kubernetes.io/backend-protocol: HTTP + hosts: + - host: aa.staging.zerodev.app # CHANGEME - confirm with team + paths: + - path: / + pathType: Prefix + servicePort: http From 3596933ee85a58ed43a686e4386fee73ffdb0a15 Mon Sep 17 00:00:00 2001 From: aravindgee Date: Wed, 12 Aug 2026 12:57:47 -0400 Subject: [PATCH 2/2] Mount private keys as files instead of env vars, set replicas: 2 Per today's SREP-2737 meeting: secrets should be retrieved as files on the container, not env vars. The CLI has no file-path option for --executor-private-keys/--utility-private-key, so the shell wrapper now reads the mounted secret files itself via $(cat ...) instead of expanding env vars. Keeps SENTRY_DSN/BETTER_STACK_* as env vars since the app reads those directly from process.env, no CLI flag exists for them. Also sets replicas: 2, matching the meeting's zero-downtime/multi-replica cutover requirement (was previously unset, defaulting to the chart's underlying default). --- deploy/common.values.yaml | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/deploy/common.values.yaml b/deploy/common.values.yaml index 5b0c214e..d0bcd400 100644 --- a/deploy/common.values.yaml +++ b/deploy/common.values.yaml @@ -20,6 +20,7 @@ application: deployment: enabled: true + replicas: 2 reloadOnChange: true configMapChecksum: true serviceAccountName: ultra-relay @@ -29,7 +30,7 @@ application: containerSecurityContext: readOnlyRootFilesystem: false runAsNonRoot: false - # No env-var binding for executor/utility private keys upstream; shell wrapper expands envFrom into flags. + # No CLI flag reads these from a file path; the wrapper reads the mounted secret files into flag values itself. command: - /bin/sh - -c @@ -37,12 +38,24 @@ application: - >- exec pnpm start run --config /configs/alto-config.json - --executor-private-keys "$EXECUTOR_PRIVATE_KEYS" - --utility-private-key "$UTILITY_PRIVATE_KEY" - envFrom: - ultra-relay-secrets: - type: secret - name: ultra-relay-secrets + --executor-private-keys "$(cat /secrets/executor-private-keys)" + --utility-private-key "$(cat /secrets/utility-private-key)" + env: + SENTRY_DSN: + valueFrom: + secretKeyRef: + name: ultra-relay-secrets + key: SENTRY_DSN + BETTER_STACK_TOKEN: + valueFrom: + secretKeyRef: + name: ultra-relay-secrets + key: BETTER_STACK_TOKEN + BETTER_STACK_ENDPOINT: + valueFrom: + secretKeyRef: + name: ultra-relay-secrets + key: BETTER_STACK_ENDPOINT ports: - containerPort: 3000 name: http @@ -67,10 +80,21 @@ application: config: mountPath: /configs/alto-config.json subPath: alto-config.json + secrets: + mountPath: /secrets + readOnly: true volumes: config: configMap: name: ultra-relay-config + secrets: + secret: + secretName: ultra-relay-secrets + items: + - key: EXECUTOR_PRIVATE_KEYS + path: executor-private-keys + - key: UTILITY_PRIVATE_KEY + path: utility-private-key resources: limits: memory: 512Mi