AkurAI Build
Menu

AkurAI-Build

public

Latest change 834088c92ff481a8a0cc0f5825c8fb9524396e7a - Build lean Git-native AkurAI CI/CD by Ólafur Búi Ólafsson

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

ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
HOST=$(uname -n)
APP=akurai
DOMAIN=akurai-builds.olibuijr.com
LISTEN=100.88.0.9:3210
INSTALL="$HOME/.local/lib/akurai-build"
STATE="$HOME/.local/share/akurai-build"
CONFIG="$HOME/.config/akurai-build"
UNIT="$HOME/.config/systemd/user/akurai-build.service"

source_digest() {
  (cd "$1" && find . -type f -not -path './.git/*' -not -path './target/*' -not -path './.refrepos/*' -print0 | xargs -0 sha256sum | sort | sha256sum | cut -d' ' -f1)
}

if [[ $HOST != titan && ${1:-} == sync ]]; then
  "$HOME/.local/bin/syncthing-ctl" rescan projects >/dev/null
  ssh titan '$HOME/.local/bin/syncthing-ctl rescan projects >/dev/null'
  local_digest=$(source_digest "$ROOT")
  for _ in {1..60}; do
    remote_digest=$(ssh titan "cd /home/olafurbui/Projects/AkurAI-Build && find . -type f -not -path './.git/*' -not -path './target/*' -not -path './.refrepos/*' -print0 | xargs -0 sha256sum | sort | sha256sum | cut -d' ' -f1")
    if [[ $local_digest == "$remote_digest" ]]; then
      printf 'synced=%s\n' "$local_digest"
      exit 0
    fi
    sleep 1
  done
  echo 'project sync did not converge within 60 seconds' >&2
  exit 1
fi

if [[ $HOST != titan ]]; then
  case ${1:-} in
    format|lock|check|docker-smoke|build|publish|release|deploy) bash "$ROOT/deploy.sh" sync ;;
  esac
  printf -v forwarded '%q ' "$@"
  exec ssh titan "cd /home/olafurbui/Projects/AkurAI-Build && bash ./deploy.sh $forwarded"
fi

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

probe() {
  command -v cargo
  command -v git
  command -v docker
  command -v ssh
  ip -brief address | grep -F '100.88.0.9/'
  docker info --format 'docker={{.ServerVersion}}'
  git status --short --branch
  ssh -o BatchMode=yes -o ConnectTimeout=10 akurai-ec2 "
    set -e
    command -v nginx
    systemctl is-active nginx
    ip route get 100.88.0.9
    if sudo test -s /etc/letsencrypt/live/$DOMAIN/fullchain.pem; then
      sudo openssl x509 -noout -enddate -in /etc/letsencrypt/live/$DOMAIN/fullchain.pem
    else
      echo certificate=missing
    fi
  "
}

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
}

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

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'
}

install_service() {
  install -d -m 700 "$INSTALL" "$STATE" "$CONFIG" "$(dirname "$UNIT")"
  systemctl --user stop akurai-build.service 2>/dev/null || true
  for secret in database.key api.token webhook.token; do
    if [[ ! -s $CONFIG/$secret ]]; then
      "$ROOT/target/release/$APP" keygen --out "$CONFIG/$secret"
    fi
  done
  if [[ ! -e $CONFIG/env ]]; then
    install -m 600 /dev/null "$CONFIG/env"
  fi
  if [[ -x $INSTALL/$APP ]]; then
    cp -a "$INSTALL/$APP" "$INSTALL/$APP.previous"
  fi
  install -m 755 "$ROOT/target/release/$APP" "$INSTALL/$APP"
  "$INSTALL/$APP" --data "$STATE" --key-file "$CONFIG/database.key" migrate
  install -m 644 "$ROOT/deploy/akurai-build.service" "$UNIT"
  systemctl --user daemon-reload
  systemctl --user enable --now akurai-build.service
}

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

vhost() {
  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"
}

status() {
  systemctl --user status akurai-build.service --no-pager
  curl --fail --silent --show-error "http://$LISTEN/api/health"
  printf '\n'
  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"}
  cd "$ROOT"
  ensure_git_identity
  git pull --rebase --autostash origin main
  if [[ -z $(git status --porcelain -- Cargo.toml CHANGELOG.md) ]]; 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"
  git push origin HEAD:main
}

case "$command" in
  sync) "$HOME/.local/bin/syncthing-ctl" rescan projects ;;
  probe) probe ;;
  bootstrap)
    chmod 755 "$ROOT/deploy.sh"
    command -v gh >/dev/null || sudo pacman -S --needed --noconfirm github-cli
    ensure_git_identity
    probe
    ;;
  lock) cd "$ROOT"; cargo generate-lockfile ;;
  version)
    if [[ ${1:-show} == show ]]; then current_version; else bump_version "$@"; fi
    ;;
  format) cd "$ROOT"; cargo fmt --all ;;
  recover-checks) recover_checks ;;
  check) check ;;
  docker-smoke) docker_smoke ;;
  build) build ;;
  build-info) build_info ;;
  deploy) build; docker_smoke; install_service; install_skill; vhost; status ;;
  skill) install_skill ;;
  vhost) vhost ;;
  status) status ;;
  cli) exec "$INSTALL/$APP" --data "$STATE" --key-file "$CONFIG/database.key" "$@" ;;
  logs) journalctl --user -u akurai-build.service -n "${1:-100}" --no-pager ;;
  rollback)
    test -x "$INSTALL/$APP.previous"
    cp -a "$INSTALL/$APP.previous" "$INSTALL/$APP"
    systemctl --user restart akurai-build.service
    status
    ;;
  publish) publish "$@" ;;
  release)
    level=${1:?usage: ./deploy.sh release patch|minor|major "summary"}
    shift
    summary=${1:?release summary required}
    bump_version "$level" "$summary"
    publish "$summary"
    build; docker_smoke; install_service; install_skill; vhost; status
    ;;
  *)
    cat <<'HELP'
Usage: ./deploy.sh <command>

  sync        Rescan midget/Titan and wait for matching source-tree digests
  probe       Read-only host, mesh, Git, Rust, and Docker checks
  bootstrap   Install GitHub CLI on Titan, then probe
  lock        Regenerate Cargo.lock after reviewed dependency changes
  version     Show version, or: version patch|minor|major "summary"
  format      Apply rustfmt to repository Rust sources
  recover-checks  Stop only stale AkurAI Cargo test processes
  check       Verify formatting, lint, and tests
  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      Build/install on Titan, ensure EC2 Nginx/TLS, and verify public health
  skill       Install/update the Pi agent skill symlink
  vhost       Check EC2/mesh/Nginx/certificate expiry and provision or renew as needed
  status      Show service and health
  cli ...     Run the installed JSON CLI against production state
  logs [N]    Show the last N service log lines
  rollback    Restore the previous installed binary
  publish M   Ensure version/changelog, check, commit, and push to GitHub main
  release L M Bump level L, changelog M, publish, deploy, and verify
HELP
    ;;
esac