AkurAI Build
Menu

AkurAI-Build

public

Latest change cef82a0db1ab211acbc32248c8374bd3be2bfe7d - ci: deploy-production runs unattended, no approval or promotion by Ólafur Búi Ólafsson

#!/usr/bin/env bash
set -euo pipefail

ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
HOST=$(uname -n)
APP=akurai
CONFIG="$HOME/.config/akurai-build"
ENV_FILE="$CONFIG/env"
if [[ -r $ENV_FILE ]]; then set -a; source "$ENV_FILE"; set +a; fi
DOMAIN=${AKURAI_BUILD_DOMAIN:-}
LISTEN=${AKURAI_BUILD_LISTEN:-100.88.0.9:3210}
INSTALL="$HOME/.local/lib/akurai-build"
STATE="$HOME/.local/share/akurai-build"
MCP_BIN="$HOME/.local/bin/akurai-build-mcp"
[[ -x $MCP_BIN ]] || MCP_BIN="$INSTALL/$APP"

if [[ $HOST != titan ]]; then
  command=${1:-help}
  if [[ $command == sync ]]; then
    git -C "$ROOT" diff --quiet
    git -C "$ROOT" diff --cached --quiet
    git -C "$ROOT" push origin HEAD
    ssh titan 'git -C /home/olibuijr/Projects/AkurAI-Build pull --ff-only'
    exit 0
  fi

  git -C "$ROOT" diff --quiet
  git -C "$ROOT" diff --cached --quiet
  git -C "$ROOT" push origin HEAD
  ssh titan 'git -C /home/olibuijr/Projects/AkurAI-Build pull --ff-only'
  printf -v forwarded '%q ' "$@"
  exec ssh titan "cd /home/olibuijr/Projects/AkurAI-Build && bash ./deploy.sh $forwarded"
fi

command=${1:-help}
shift || true

configure_domain() {
  local domain=${1:?usage: ./deploy.sh configure-domain <hostname>}
  [[ $domain =~ ^[a-z0-9.-]+$ ]] || { echo 'invalid hostname' >&2; return 1; }
  install -d -m 700 "$CONFIG"
  touch "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  grep -q '^AKURAI_BUILD_DOMAIN=' "$ENV_FILE" && sed -i "s/^AKURAI_BUILD_DOMAIN=.*/AKURAI_BUILD_DOMAIN=$domain/" "$ENV_FILE" || printf 'AKURAI_BUILD_DOMAIN=%s\n' "$domain" >> "$ENV_FILE"
  grep -q '^AKURAI_PUBLIC_URL=' "$ENV_FILE" && sed -i "s|^AKURAI_PUBLIC_URL=.*|AKURAI_PUBLIC_URL=https://$domain|" "$ENV_FILE" || printf 'AKURAI_PUBLIC_URL=https://%s\n' "$domain" >> "$ENV_FILE"
}

edge_probe() {
  [[ -n $DOMAIN ]] || { echo 'AKURAI_BUILD_DOMAIN is not configured' >&2; return 1; }
  local address http https certificate failed=0
  address=$(getent ahostsv4 "$DOMAIN" | awk 'NR == 1 { print $1 }')
  [[ -n $address ]] || { echo 'edge DNS is unresolved' >&2; return 1; }
  http=$(curl --silent --output /dev/null --write-out '%{http_code}' --connect-timeout 5 "http://$DOMAIN/" || printf 000)
  https=$(curl --insecure --silent --output /dev/null --write-out '%{http_code}' --connect-timeout 5 "https://$DOMAIN/" || printf 000)
  certificate=$(timeout 10s openssl s_client -showcerts -connect "$DOMAIN:443" -servername "$DOMAIN" </dev/null 2>/dev/null || true)
  printf 'edge_ip=%s http=%s https=%s\n' "$address" "$http" "$https"
  if printf '%s' "$certificate" | openssl x509 -noout -enddate; then
    if printf '%s' "$certificate" | openssl x509 -checkend 2592000 -noout; then
      echo 'certificate_30d=valid'
    else
      echo 'certificate_30d=renewal-required'
      failed=1
    fi
  else
    echo 'certificate=missing-or-invalid'
    failed=1
  fi
  if timeout 10s openssl s_client -verify_return_error -verify_hostname "$DOMAIN" -connect "$DOMAIN:443" -servername "$DOMAIN" </dev/null >/dev/null 2>&1; then
    echo 'certificate_chain_hostname=valid'
  else
    echo 'certificate_chain_hostname=invalid'
    failed=1
  fi
  if ssh -o BatchMode=yes -o ConnectTimeout=10 akurai-ec2 true; then
    echo 'akurai_mesh_ssh=reachable'
  else
    echo 'akurai_mesh_ssh=unreachable; restore akurai-node-tunnel.service on EC2' >&2
    failed=1
  fi
  return "$failed"
}

mesh_recover() {
  sudo systemctl restart akurai-node-tunnel.service
  ssh -o BatchMode=yes -o ConnectTimeout=10 akurai-ec2 \
    'sudo systemctl restart akurai-node.service'
  for _ in {1..24}; do
    if ssh -o BatchMode=yes -o ConnectTimeout=10 akurai-ec2 \
      "curl --fail --silent --show-error --connect-timeout 5 http://$LISTEN/api/health >/dev/null"; then
      echo 'akurai_mesh_upstream=reachable'
      return
    fi
    sleep 2
  done
  echo 'akurai_mesh_upstream=unreachable after tunnel restart' >&2
  return 1
}

probe() {
  command -v cargo
  command -v git
  command -v docker
  command -v ssh
  ip -brief address | grep -F '100.88.0.9/'
  "$HOME/.akurai-vpn/bin/akurai-node" status
  systemctl is-active akurai-node-tunnel.service
  docker info --format 'docker={{.ServerVersion}}'
  if [[ -r /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
    grep -E '^(enabled|flags)' /proc/sys/fs/binfmt_misc/qemu-aarch64
  else
    echo 'binfmt_arm64=missing'
  fi
  git status --short --branch
  edge_probe
}

check() {
  cd "$ROOT"
  bash -n "$ROOT/deploy.sh"
  timeout 30s cargo fmt --check
  timeout 180s cargo clippy --locked --all-targets -- -D warnings
  timeout --kill-after=5s 90s cargo test --locked --all-targets --no-fail-fast
}

build() {
  check
  timeout --kill-after=5s 600s cargo build --release --locked
}

binfmt() {
  if [[ ! -r /proc/sys/fs/binfmt_misc/qemu-aarch64 ]] || ! grep -q '^enabled' /proc/sys/fs/binfmt_misc/qemu-aarch64; then
    timeout --kill-after=5s 120s docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68 --install arm64
  fi
  grep -E '^(enabled|flags)' /proc/sys/fs/binfmt_misc/qemu-aarch64
  timeout --kill-after=5s 120s docker run --rm --platform linux/arm64 alpine:3.22 uname -m | grep -Fx aarch64
}

docker_smoke() {
  cd "$ROOT"
  timeout --kill-after=5s 180s cargo test --locked --test docker_smoke -- --ignored --nocapture
}

clean_build() {
  cd "$ROOT"
  cargo clean
}

build_info() {
  local binary="$ROOT/target/release/$APP"
  test -x "$binary"
  "$binary" --version
  printf 'bytes=%s\nsha256=' "$(stat -c %s "$binary")"
  sha256sum "$binary" | cut -d' ' -f1
}

recover_checks() {
  mapfile -t pids < <(pgrep -f "^$HOME/.*/cargo test --locked --all-targets --no-fail-fast$|^$ROOT/target/debug/deps/akurai_build-[0-9a-f]+$" || true)
  ((${#pids[@]})) || { echo 'no stale AkurAI checks'; return; }
  kill -TERM "${pids[@]}"
  for _ in {1..5}; do
    remaining=()
    for pid in "${pids[@]}"; do kill -0 "$pid" 2>/dev/null && remaining+=("$pid"); done
    ((${#remaining[@]} == 0)) && { echo 'stale checks stopped'; return; }
    sleep 1
  done
  kill -KILL "${remaining[@]}"
  echo 'stale checks killed'
}

pipeline_deploy_only() {
  echo 'Direct deployment is retired. Use the AkurAI Build MCP release/run/promotion path; deploy/service-deploy.sh owns the self-hosted service rollout.' >&2
  return 1
}

install_skill() {
  local target="$HOME/.skills/akurai-build"
  install -d "$(dirname "$target")"
  ln -sfn "$ROOT/skills/akurai-build" "$target"
}

vhost() {
  edge_probe || true
  local ssh=(ssh -o BatchMode=yes -o ConnectTimeout=10 akurai-ec2)
  "${ssh[@]}" 'true'
  if ! "${ssh[@]}" 'command -v nginx >/dev/null && command -v certbot >/dev/null'; then
    "${ssh[@]}" 'sudo apt-get update && sudo apt-get install -y nginx certbot python3-certbot-nginx'
  fi
  "${ssh[@]}" "curl --fail --silent --show-error --connect-timeout 10 http://$LISTEN/api/health >/dev/null"

  if ! "${ssh[@]}" "sudo test -s /etc/letsencrypt/live/$DOMAIN/fullchain.pem"; then
    "${ssh[@]}" "sudo tee /etc/nginx/sites-available/$DOMAIN >/dev/null && sudo ln -sfn /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN && sudo nginx -t && sudo systemctl reload nginx" <<NGINX_HTTP
server {
    listen 80;
    listen [::]:80;
    server_name $DOMAIN;
    location / {
        proxy_pass http://$LISTEN;
        proxy_set_header Host \$host;
        proxy_set_header X-Forwarded-Proto \$scheme;
    }
}
NGINX_HTTP
    "${ssh[@]}" "sudo certbot certonly --nginx --non-interactive --agree-tos --register-unsafely-without-email -d $DOMAIN"
  elif ! "${ssh[@]}" "sudo openssl x509 -checkend 2592000 -noout -in /etc/letsencrypt/live/$DOMAIN/fullchain.pem"; then
    "${ssh[@]}" "sudo certbot renew --cert-name $DOMAIN --deploy-hook 'systemctl reload nginx'"
  fi

  "${ssh[@]}" "sudo tee /etc/nginx/sites-available/$DOMAIN >/dev/null && sudo ln -sfn /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/$DOMAIN && sudo nginx -t && sudo systemctl reload nginx" <<NGINX_TLS
server {
    listen 80;
    listen [::]:80;
    server_name $DOMAIN;
    return 301 https://\$host\$request_uri;
}
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name $DOMAIN;
    ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    add_header Strict-Transport-Security "max-age=31536000" always;
    client_max_body_size 1m;
    location / {
        proxy_pass http://$LISTEN;
        proxy_http_version 1.1;
        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 https;
        proxy_read_timeout 3600;
        proxy_buffering off;
    }
}
NGINX_TLS
  "${ssh[@]}" "if systemctl list-unit-files certbot.timer >/dev/null 2>&1; then sudo systemctl enable --now certbot.timer; fi"
  "${ssh[@]}" "sudo openssl x509 -noout -enddate -in /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
}

http_smoke() {
  local base="http://$LISTEN" headers code
  curl --fail --silent --show-error "$base/" | grep -Fq 'AkurAI Build'
  headers=$(curl --silent --show-error --head "$base/")
  grep -Fiq 'content-security-policy:' <<<"$headers"
  grep -Fiq "script-src 'self'" <<<"$headers"
  grep -Fiq 'x-frame-options: DENY' <<<"$headers"
  grep -Fiq 'x-content-type-options: nosniff' <<<"$headers"
  code=$(curl --silent --output /dev/null --write-out '%{http_code}' "$base/api/state")
  [[ $code == 401 ]] || { echo "unauthenticated API returned $code" >&2; return 1; }
  echo 'http_smoke=ok'
}

wait_local() {
  for _ in {1..60}; do
    if curl --fail --silent "http://$LISTEN/api/health" >/dev/null 2>&1 \
      && curl --fail --silent "http://127.0.0.1:3211/api/health" >/dev/null 2>&1; then
      return 0
    fi
    sleep 0.25
  done
  docker logs --tail 50 akurai-build-service >&2 || true
  docker logs --tail 50 akurai-build-worker >&2 || true
  echo 'AkurAI Build service or worker did not become ready within 15 seconds' >&2
  return 1
}

status_local() {
  wait_local
  systemctl is-active akurai-build.service
  docker ps --filter 'name=^/akurai-build-(service|worker)$' --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
  curl --fail --silent --show-error "http://$LISTEN/api/health"
  printf '\n'
  curl --fail --silent --show-error "http://127.0.0.1:3211/api/health"
  printf '\n'
  http_smoke
}

status() {
  status_local
  curl --fail --silent --show-error --connect-timeout 15 "https://$DOMAIN/api/health"
  printf '\n'
}

current_version() {
  awk -F '"' '/^version = "/ { print $2; exit }' "$ROOT/Cargo.toml"
}

validate_release_files() {
  local cargo latest
  cargo=$(current_version)
  latest=$(awk '/^## [0-9]+\.[0-9]+\.[0-9]+ / { print $2; exit }' "$ROOT/CHANGELOG.md")
  [[ $cargo == "$latest" ]] || { echo "Cargo version $cargo does not match changelog $latest" >&2; return 1; }
}

bump_version() {
  local level=${1:?version level required} summary=${2:?changelog summary required}
  [[ $summary != *$'\n'* ]] || { echo 'changelog summary must be one line' >&2; return 1; }
  local current major minor patch
  current=$(current_version)
  IFS=. read -r major minor patch <<<"$current"
  if [[ $current == 0.1.0 ]]; then
    major=1; minor=1; patch=1
  else
    case "$level" in
      patch)
        ((patch += 1))
        if ((patch > 10)); then patch=1; ((minor += 1)); fi
        if ((minor > 10)); then minor=1; ((major += 1)); fi
        ;;
      minor)
        ((minor += 1)); patch=1
        if ((minor > 10)); then minor=1; ((major += 1)); fi
        ;;
      major) ((major += 1)); minor=1; patch=1 ;;
      *) echo 'level must be patch, minor, or major' >&2; return 1 ;;
    esac
  fi
  ((major >= 1 && major <= 10 && minor >= 1 && minor <= 10 && patch >= 1 && patch <= 10)) || {
    echo 'version range exhausted at 10.10.10' >&2; return 1;
  }
  local next="$major.$minor.$patch" temporary
  sed -i "0,/^version = \"$current\"/s//version = \"$next\"/" "$ROOT/Cargo.toml"
  temporary=$(mktemp "$ROOT/.changelog.XXXXXX")
  {
    printf '# Changelog\n\n## %s - %s\n\n- %s\n\n' "$next" "$(date -u +%F)" "$summary"
    if [[ -f $ROOT/CHANGELOG.md ]]; then
      awk 'NR == 1 && $0 == "# Changelog" { next } { print }' "$ROOT/CHANGELOG.md" | sed '/./,$!d'
    fi
  } >"$temporary"
  mv "$temporary" "$ROOT/CHANGELOG.md"
  (cd "$ROOT" && cargo generate-lockfile)
  validate_release_files
  printf '%s\n' "$next"
}

ensure_git_identity() {
  git -C "$ROOT" config user.name 'Ólafur Búi Ólafsson'
  git -C "$ROOT" config user.email 'olibuijr@olibuijr.com'
}

publish() {
  local message=${1:?usage: ./deploy.sh publish "message"} head_version
  cd "$ROOT"
  ensure_git_identity
  # Titan's working checkout is the single source of truth; nothing to pull from the mirror.
  head_version=$(git show HEAD:Cargo.toml | awk -F '"' '/^version = "/ { print $2; exit }')
  if [[ $(current_version) == "$head_version" ]]; then
    bump_version patch "$message" >/dev/null
  else
    validate_release_files
  fi
  check
  git add -A
  git diff --cached --quiet && { echo 'nothing to publish'; return; }
  git commit -m "$message"
  local request response
  request=$(printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"akurai_repo_sync","arguments":{"name":"AkurAI-Build","source":"%s"}}}' "$ROOT")
  response=$("$MCP_BIN" --data "$STATE" --key-file "$CONFIG/database.key" mcp <<<"$request")
  [[ $response != *'"isError":true'* ]] || { echo "repo sync failed: $response" >&2; return 1; }
}

case "$command" in
  sync) "$HOME/.local/bin/syncthing-ctl" rescan projects ;;
  edge-probe) edge_probe ;;
  mesh-recover) mesh_recover ;;
  http-smoke) http_smoke ;;
  probe) probe ;;
  bootstrap)
    chmod 755 "$ROOT/deploy.sh"
    command -v gh >/dev/null || sudo pacman -S --needed --noconfirm github-cli
    ensure_git_identity
    binfmt
    probe
    ;;
  lock) cd "$ROOT"; cargo generate-lockfile ;;
  configure-domain) configure_domain "$@" ;;
  version)
    if [[ ${1:-show} == show ]]; then current_version; else bump_version "$@"; fi
    ;;
  format) cd "$ROOT"; cargo fmt --all ;;
  clean-build) clean_build ;;
  recover-checks) recover_checks ;;
  check) check ;;
  binfmt) binfmt ;;
  docker-smoke) docker_smoke ;;
  build) build ;;
  build-info) build_info ;;
  deploy-local|deploy) pipeline_deploy_only ;;
  skill) install_skill ;;
  vhost) vhost ;;
  status-local) status_local ;;
  status) status ;;
  mcp) exec "$MCP_BIN" --data "$STATE" --key-file "$CONFIG/database.key" mcp ;;
  logs) docker logs --tail "${1:-100}" akurai-build-service; docker logs --tail "${1:-100}" akurai-build-worker ;;
  rollback) pipeline_deploy_only ;;
  publish) publish "$@" ;;
  release) pipeline_deploy_only ;;
  *)
    cat <<'HELP'
Usage: ./deploy.sh <command>

  sync        Push the current commit, then fast-forward Titan from origin
  edge-probe  Check DNS, HTTP/S, certificate expiry, and EC2 mesh SSH
  mesh-recover Restart Titan's VPN tunnel and require EC2 upstream recovery
  http-smoke  Verify private UI, security headers, health, and API rejection
  probe       Read-only host, VPN, edge, Git, Rust, and Docker checks
  bootstrap   Install GitHub CLI on Titan, then probe
  configure-domain  Store the public domain/origin in the protected environment file
  lock        Regenerate Cargo.lock after reviewed dependency changes
  version     Show version, or: version patch|minor|major "summary"
  format      Apply rustfmt to repository Rust sources
  clean-build  Remove only this repository's Cargo build artifacts
  recover-checks  Stop only stale AkurAI Cargo test processes
  check       Verify formatting, lint, and tests
  binfmt      Ensure pinned ARM64 QEMU registration and run an ARM smoke command
  docker-smoke  Run the ignored restricted-container/artifact E2E on Titan
  build       Check and release-build
  build-info  Show release binary version, size, and SHA-256
  deploy-local  Retired: push to main, the self-pipeline verifies/packages/deploys unattended
  deploy      Retired: push to main, the self-pipeline verifies/packages/deploys unattended
  skill       Install/update the Pi agent skill symlink
  vhost       Check EC2/mesh/Nginx/certificate expiry and provision or renew as needed
  status-local  Show Titan service and private-mesh health
  status      Show Titan and public HTTPS health
  mcp         Serve the AkurAI Build MCP tool set over stdio
  logs [N]    Show the last N service and worker log lines
  rollback    Retired: deploy-production rolls back automatically on failed health
  publish M   Legacy source publication helper; agents use akurai_repo_sync MCP
  release L M Retired: use akurai_release MCP
HELP
    ;;
esac