Files
ai-memory/bin/deploy
T
AkitaOnRailsandClaude Opus 5 e40b9964b1 fix(deploy): refuse to flatten a multi-arch tag to one architecture
`bin/deploy` builds with plain `docker build`, which produces an image for
the architecture of the machine running it. Pushing that to a tag whose
current manifest is a multi-arch list replaces the list outright, and every
host on the other architecture fails the next pull with `exec format error`.

This is not hypothetical — it happened to `:latest` on 2026-08-18 and was
reported within hours as #427. The release workflow builds amd64 and arm64
and joins them into a manifest; a homelab deploy from an x86 workstation
then silently flattened that to amd64-only. The registry has been repaired
by re-pointing `:latest` at the `:1.28.1` manifest list.

Two changes so it cannot recur:

- `bin/deploy` inspects the target tag first and exits 65 if it already
  resolves to a manifest list, naming the private-tag fix and the
  pull-only alternative for deploying a released image.
- `bin/deploy.env.example` shipped `IMAGE="akitaonrails/ai-memory:latest"`,
  so the documented setup pointed the operator's build at the public
  release tag by default. It now defaults to `:homelab`.

Refs #427

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:16:30 -03:00

124 lines
4.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build + push + deploy ai-memory to a homelab Docker host.
#
# Reads SERVER, DEPLOY_DIR, and IMAGE from bin/deploy.env (which is
# .gitignored — see bin/deploy.env.example for the template). The
# script itself is host-agnostic; everything operator-specific lives
# in that sourced file.
#
# Initial setup (one-time, before the first deploy):
#
# 1. Copy bin/deploy.env.example to bin/deploy.env and fill in
# your homelab specifics.
# 2. Copy docker/docker-compose.prod.yml.example to
# docker/docker-compose.prod.yml; adjust ports / volumes if
# needed.
# 3. Copy docker/.env.production.example to docker/.env.production
# and fill in API keys (LLM + embeddings).
# 4. SSH into the homelab and create the deploy dir:
# ssh "$SERVER" "sudo mkdir -p $DEPLOY_DIR && \
# sudo chown $(whoami): $DEPLOY_DIR"
# 5. scp the compose + env files up there:
# scp docker/docker-compose.prod.yml "$SERVER:$DEPLOY_DIR/docker-compose.yml"
# scp docker/.env.production "$SERVER:$DEPLOY_DIR/.env.production"
# 6. Now `bin/deploy` works.
#
# See docs/deploy.md for the full setup walkthrough.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$SCRIPT_DIR/deploy.env"
if [[ ! -f "$ENV_FILE" ]]; then
cat >&2 <<EOF
==> bin/deploy.env not found.
This file holds your homelab-specific values (server, deploy dir,
image tag) and is gitignored. Copy the template and fill it in:
cp bin/deploy.env.example bin/deploy.env
\$EDITOR bin/deploy.env
Then re-run bin/deploy. See docs/deploy.md for details.
EOF
exit 64
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
: "${SERVER:?SERVER not set in bin/deploy.env}"
: "${DEPLOY_DIR:?DEPLOY_DIR not set in bin/deploy.env}"
: "${IMAGE:?IMAGE not set in bin/deploy.env}"
SSH_OPTS=("-F" "/dev/null" "-o" "StrictHostKeyChecking=accept-new")
CONTAINER_NAME="${CONTAINER_NAME:-ai-memory}"
cd "$REPO_ROOT"
# Fail closed if $IMAGE already carries a multi-architecture manifest.
#
# `docker build` below produces an image for THIS machine's architecture
# only. Pushing that to a tag whose current manifest is a multi-arch list
# replaces the list, and every host on the other architecture starts
# failing with `exec format error` on the next pull.
#
# That is not hypothetical: it happened to `:latest` on 2026-08-18 (issue
# #427). `bin/release` + the release workflow publish amd64 and arm64 and
# join them into a manifest; a homelab deploy from an x86 workstation then
# silently flattened it to amd64-only. Release tags are published by CI —
# a deploy has no business overwriting one.
if docker manifest inspect "$IMAGE" >/dev/null 2>&1 &&
docker manifest inspect "$IMAGE" 2>/dev/null | grep -q '"manifests"'; then
cat >&2 <<EOF
==> REFUSING to overwrite a multi-architecture tag.
$IMAGE currently resolves to a multi-arch manifest (published by
the release workflow). This script builds for $(uname -m) only, so
pushing would drop every other architecture and break those hosts
with "exec format error".
Deploy a private tag instead — set IMAGE in bin/deploy.env to
something like:
IMAGE="akitaonrails/ai-memory:homelab"
and point docker-compose.yml on the server at that tag. Release
tags (:latest, :X.Y.Z) are published by CI only.
To deploy the released image without rebuilding, skip this script
and just pull it on the server:
ssh \$SERVER "cd \$DEPLOY_DIR && docker compose pull && docker compose up -d"
EOF
exit 65
fi
echo "==> Building Docker image: $IMAGE"
docker build -t "$IMAGE" -f docker/Dockerfile .
echo "==> Pushing image to registry..."
docker push "$IMAGE"
echo "==> Deploying to $SERVER:$DEPLOY_DIR"
# `docker compose pull` fetches the new tag; `down` stops the old
# container; `up -d` starts the new one with the new tag. We don't
# `docker compose build` on the server because the binary travels
# baked into the image.
ssh "${SSH_OPTS[@]}" "$SERVER" "cd $DEPLOY_DIR && \
docker compose pull && \
docker compose down && \
docker compose up -d"
echo "==> Waiting for container to come up..."
sleep 3
ssh "${SSH_OPTS[@]}" "$SERVER" \
"docker inspect --format='{{.State.Status}} ({{.State.Health.Status}})' $CONTAINER_NAME"
echo "==> Deploy complete."
echo
echo " Container: $CONTAINER_NAME on $SERVER"
echo " Probe MCP: curl ${SERVER#*@}:49374/mcp"