diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index a2c19866..26e37ef6 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -31,19 +31,29 @@ concurrency: cancel-in-progress: true jobs: - publish: - runs-on: ubuntu-latest + # One job per image per architecture, each on a native runner: the arm64 image is + # built and boot-checked on arm64 hardware, not under emulation, so "it boots" means + # the same thing on a Graviton instance as on x86. Each job pushes its image by + # digest only; `manifest` below gives the pair its tags. + build: + runs-on: ${{ matrix.runner }} strategy: - # One failing image should not hide whether the other built. + # One failing image or architecture should not hide whether the others built. fail-fast: false matrix: - include: + image: - name: gateway dockerfile: deploy/Dockerfile context: . - name: dashboard dockerfile: deploy/Dockerfile.dashboard context: ./dashboard + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 @@ -58,27 +68,20 @@ jobs: # the run, so there is no long-lived credential to rotate or leak. password: ${{ secrets.GITHUB_TOKEN }} - - name: Work out the tags + - name: Work out the labels id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }}/${{ matrix.name }} - # `edge` tracks main; a vX.Y.Z tag also moves `latest` and publishes the - # major.minor so a deployment can pin how precisely it wants to. - tags: | - type=raw,value=edge,enable={{is_default_branch}} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + images: ghcr.io/${{ github.repository }}/${{ matrix.image.name }} labels: | - org.opencontainers.image.title=agentfox-${{ matrix.name }} - org.opencontainers.image.description=AgentFox ${{ matrix.name }} — runtime firewall for AI agents + org.opencontainers.image.title=agentfox-${{ matrix.image.name }} + org.opencontainers.image.description=AgentFox ${{ matrix.image.name }} — runtime firewall for AI agents org.opencontainers.image.licenses=Apache-2.0 # The gateway image carries ~6GB of model weights; the runner's preinstalled # toolchains leave too little disk to load it into the local daemon. - name: Free disk for the gateway image - if: matrix.name == 'gateway' + if: matrix.image.name == 'gateway' run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL df -h / @@ -87,28 +90,94 @@ jobs: # the `deps` stage, so nothing ran the image until somebody pulled it — which is # how a gateway whose detector warm-up crashed its own startup got this far. - name: Build the gateway image locally - if: matrix.name == 'gateway' + if: matrix.image.name == 'gateway' uses: docker/build-push-action@v6 with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} + context: ${{ matrix.image.context }} + file: ${{ matrix.image.dockerfile }} load: true tags: agentfox-gateway:boot-check - cache-from: type=gha + # amd64 keeps the default scope, shared with ci.yml's docker-smoke. + cache-from: type=gha${{ matrix.arch == 'arm64' && ',scope=arm64' || '' }} - name: The gateway image boots - if: matrix.name == 'gateway' + if: matrix.image.name == 'gateway' run: scripts/check/boot_gateway_image.sh agentfox-gateway:boot-check - - name: Build and push + - name: Build and push by digest + id: build uses: docker/build-push-action@v6 with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - push: true - tags: ${{ steps.meta.outputs.tags }} + context: ${{ matrix.image.context }} + file: ${{ matrix.image.dockerfile }} + platforms: linux/${{ matrix.arch }} labels: ${{ steps.meta.outputs.labels }} - # Shared with ci.yml's docker-smoke, so a PR that already built this layer - # set does not make the publish pay for it again. - cache-from: type=gha - cache-to: type=gha,mode=max + outputs: type=image,name=ghcr.io/${{ github.repository }}/${{ matrix.image.name }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha${{ matrix.arch == 'arm64' && ',scope=arm64' || '' }} + cache-to: type=gha,mode=max${{ matrix.arch == 'arm64' && ',scope=arm64' || '' }} + + - name: Keep the digest for the manifest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image.name }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # One tag, both architectures: `docker pull` picks the right one for the machine. + manifest: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + name: [gateway, dashboard] + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-${{ matrix.name }}-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - name: Sign in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Work out the tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}/${{ matrix.name }} + # `edge` tracks main; a vX.Y.Z tag also moves `latest` and publishes the + # major.minor so a deployment can pin how precisely it wants to. + tags: | + type=raw,value=edge,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + + - name: Publish the multi-arch tags + working-directory: /tmp/digests + run: | + image="ghcr.io/${{ github.repository }}/${{ matrix.name }}" + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${image}@sha256:%s " *) + + - name: Both architectures are there + run: | + image="ghcr.io/${{ github.repository }}/${{ matrix.name }}" + tag="$(jq -r '.tags[0]' <<< "$DOCKER_METADATA_OUTPUT_JSON" | sed 's/.*://')" + docker buildx imagetools inspect "${image}:${tag}" | tee /tmp/inspect + grep -q "linux/amd64" /tmp/inspect && grep -q "linux/arm64" /tmp/inspect diff --git a/deploy/model-server/.env.example b/deploy/model-server/.env.example index 809a17bb..4f3a2714 100644 --- a/deploy/model-server/.env.example +++ b/deploy/model-server/.env.example @@ -2,11 +2,14 @@ MODEL_SERVER_DOMAIN=models.example.com # Shared with the gateway (AGENTFOX_MODEL_SERVER_TOKEN there too). -# Generate one: python -c "import secrets; print(secrets.token_urlsafe(32))" +# Generate one: python3 -c "import secrets; print(secrets.token_urlsafe(32))" AGENTFOX_MODEL_SERVER_TOKEN= -# A pinned release, so this machine changes only when you pull on purpose. -AGENTFOX_IMAGE=ghcr.io/architsharm/agentfox/gateway:latest +# Which image to run. `edge` is main. Once it works, pin what you tested so this +# machine changes only when you decide: a commit (`:sha-<7 chars>`) or a release +# (`:0.5.0`). `latest` moves only on releases and can lag main by weeks. +# Published for amd64 and arm64 (Graviton); `docker pull` picks the right one. +AGENTFOX_IMAGE=ghcr.io/architsharm/agentfox/gateway:edge # auto | cpu | cuda AGENTFOX_MODEL_DEVICE=auto diff --git a/deploy/model-server/README.md b/deploy/model-server/README.md index a32df888..c954b91c 100644 --- a/deploy/model-server/README.md +++ b/deploy/model-server/README.md @@ -22,8 +22,10 @@ with its URL. The detectors that need it are then skipped, never silently passed The classifier and backstop take about 1.5–2 GB of RAM and 25–50 ms per short prompt on CPU. -- **Instance:** c7i.large or c6i.large (2 vCPU, 4 GB). Avoid burstable `t3`/`t4g`: - once CPU credits run out, inference slows and requests start timing out. +- **Instance:** c7g.large (Graviton, arm64) or c7i.large / c6i.large (x86), each + 2 vCPU and 4 GB. The image is published for both architectures. Avoid burstable + `t3`/`t4g`: once CPU credits run out, inference slows and requests start timing out. +- **Disk:** at least 30 GB. The image is about 6 GB with its weights. - **Region:** the same region as the gateway. Vercel's default `iad1` is AWS `us-east-1`. Each hop across regions adds tens of milliseconds to a 300 ms budget. - **Security group:** inbound 443 and 80 (Let's Encrypt), and SSH from your address @@ -32,14 +34,19 @@ on CPU. Then, on the instance: ```bash -sudo dnf install -y docker && sudo systemctl enable --now docker # Amazon Linux 2023 -# docker compose plugin: https://docs.docker.com/compose/install/linux/ +# Docker and the compose plugin: https://docs.docker.com/engine/install/ +# (Amazon Linux 2023: sudo dnf install -y docker && sudo systemctl enable --now docker) +sudo usermod -aG docker $USER && newgrp docker # run docker without sudo git clone https://github.com/architsharm/agentfox && cd agentfox/deploy/model-server cp .env.example .env # set MODEL_SERVER_DOMAIN and AGENTFOX_MODEL_SERVER_TOKEN -docker compose up -d +docker compose pull && docker compose up -d +docker compose logs -f models # ready once each model logs "warm in …" curl -H "Authorization: Bearer $TOKEN" https://$MODEL_SERVER_DOMAIN/health ``` +Generate the token on the machine and paste it only into `.env` and the gateway's +settings. Not into chat, tickets or shell history you share. + `/health` lists each detector, whether it can run, and the device it runs on. Then point the gateway at it. On Vercel, these are project environment variables: @@ -61,8 +68,9 @@ every gateway deploy: docker compose pull && docker compose up -d ``` -Pin `AGENTFOX_IMAGE` to a release tag (`ghcr.io/architsharm/agentfox/gateway:0.4.0`) -so a pull is always a decision. Mismatched versions are tolerated: the gateway sends +`.env` starts on `:edge`, which is main. Once it works, pin what you tested so a pull +is always a decision: a commit (`:sha-1a2b3c4`, one is published for every push to +main) or a release (`:0.5.0`). Avoid `:latest`: it moves only on releases. Mismatched versions are tolerated: the gateway sends a detector name and text, and reads back detections. ## GPU diff --git a/deploy/model-server/docker-compose.yml b/deploy/model-server/docker-compose.yml index c09e0a92..3eb93991 100644 --- a/deploy/model-server/docker-compose.yml +++ b/deploy/model-server/docker-compose.yml @@ -13,9 +13,9 @@ services: models: # The published gateway image already carries every permissive-licence model's - # weights. Pin a release tag rather than :edge, so this machine changes only - # when you decide it should. - image: ${AGENTFOX_IMAGE:-ghcr.io/architsharm/agentfox/gateway:latest} + # weights, for amd64 and arm64. Pin a `sha-` or version tag in .env once it works, + # so this machine changes only when you decide it should. + image: ${AGENTFOX_IMAGE:-ghcr.io/architsharm/agentfox/gateway:edge} command: ["agentfox", "serve", "models", "--host", "0.0.0.0", "--port", "8090"] restart: unless-stopped environment: