Menu
AkurAI-Build
publicLatest change 3ea0d3c53d6162c5017e65eb932aea8560de1eab - Harden deployment and edge diagnostics 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-local|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
edge_probe() {
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"
}
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}}'
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
}
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() {
edge_probe
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_local() {
systemctl --user status akurai-build.service --no-pager
curl --fail --silent --show-error "http://$LISTEN/api/health"
printf '\n'
}
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"}
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 ;;
edge-probe) edge_probe ;;
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-local) build; docker_smoke; install_service; install_skill; status_local ;;
deploy) build; docker_smoke; install_service; install_skill; vhost; status ;;
skill) install_skill ;;
vhost) vhost ;;
status-local) status_local ;;
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
edge-probe Check DNS, HTTP/S, certificate expiry, and EC2 mesh SSH
probe Read-only host, VPN, edge, 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-local Build/install and verify Titan without touching the public edge
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-local Show Titan service and private-mesh health
status Show Titan and public HTTPS 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