oikos-web: extract the client stack from dtoro/oikos
Phase 1 of the hexagonal-architecture plan (dtoro/oikos plans/2026-08-15-hexagonal-architecture.md). Moves the delivery stack for the control-room UI into its own repo with its own pipeline: - web/ — Svelte 5 SPA, verbatim (vendor/ included) - desktop/ — Wails v3 wrapper, updateURL repointed to dtoro/oikos-web releases - compose/ — Dockerfile + Caddyfile, verbatim (the /wails/* 404 and asset no-fallback quirks are load-bearing) - docker-compose.yml — single web service, same 8091:80 publish, mem/cpu limits, and restart policy as the oikos stack's web service - scripts/deploy.sh — mirrors oikos deploy essentials: CI-green gate, TOCTOU guard, version-tagged oikos-web:v$VERSION, prune to 3 - cmd/webhook + scripts/install-webhook.sh — standalone push-to-deploy receiver on :9798 (env-only secrets, no Infisical dependency) - CI: the web job from oikos's ci.yml + the desktop build/release workflow, path-adjusted Own VERSION (0.33.0) with the same bump-on-main rule; starts above oikos's 0.32.x so the desktop updater sees an upgrade.
This commit is contained in:
223
scripts/deploy.sh
Executable file
223
scripts/deploy.sh
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/bin/sh
|
||||
# oikos-web deploy script — triggered by Gitea webhook on push to dtoro/oikos-web.
|
||||
# Runs on mac-mini as non-root user via launchd unit oikos-web-deploy-webhook.service.
|
||||
#
|
||||
# Mirrors the essentials of dtoro/oikos's scripts/deploy.sh (plans D1/D2 there):
|
||||
# D1 — CI gate: blocks deploy unless Gitea reports a green run for the SHA.
|
||||
# D2 — versioned images: tags the image v$VERSION (from VERSION file),
|
||||
# keeps the last 3 tags for rollback.
|
||||
# No pg_dump / seed steps — this stack serves static files only.
|
||||
|
||||
# Notify on deploy failure. Uses the Oikos API to raise an event so the
|
||||
# scheduler picks it up (best-effort, silent failure).
|
||||
notify_deploy_failure() {
|
||||
local reason="$1"
|
||||
local sha="${SHA:-unknown}"
|
||||
echo "NOTIFY: deploy failed — $reason"
|
||||
if [ -n "${OIKOS_API_TOKEN:-}" ]; then
|
||||
curl -sf -X POST "http://localhost:8090/api/v1/events" \
|
||||
-H "Authorization: Bearer $OIKOS_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"type\":\"deploy.failed\",\"severity\":\"critical\",\"source\":\"webhook\",\"data\":{\"repo\":\"oikos-web\",\"sha\":\"$sha\",\"reason\":\"$reason\"}}" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$PWD}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8091/}"
|
||||
RETRIES=${RETRIES:-30}
|
||||
SLEEP=${SLEEP:-2}
|
||||
|
||||
# CI gate (D1). Set GITEA_URL + GITEA_TOKEN to enable; without them the gate
|
||||
# is skipped with a warning (dev/local builds).
|
||||
GITEA_URL="${GITEA_URL:-}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-dtoro}"
|
||||
GITEA_REPO="${GITEA_REPO:-oikos-web}"
|
||||
CI_POLL_INTERVAL="${CI_POLL_INTERVAL:-15}"
|
||||
CI_TIMEOUT="${CI_TIMEOUT:-1200}"
|
||||
|
||||
# Serialize deploys (mkdir lock — atomic on POSIX, no flock on macOS).
|
||||
LOCKDIR="${LOCKDIR:-/tmp/oikos-web-deploy.lock}"
|
||||
if ! mkdir "$LOCKDIR" 2>/dev/null; then
|
||||
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || echo "")
|
||||
if [ -n "$oldpid" ] && kill -0 "$oldpid" 2>/dev/null; then
|
||||
echo "deploy already in progress (pid $oldpid) — exiting"
|
||||
exit 0
|
||||
fi
|
||||
echo "removing stale deploy lock (pid ${oldpid:-?} not running)"
|
||||
rm -rf "$LOCKDIR"
|
||||
mkdir "$LOCKDIR"
|
||||
fi
|
||||
echo $$ > "$LOCKDIR/pid"
|
||||
trap 'rc=$?; rm -rf "$LOCKDIR" 2>/dev/null || true; if [ "$_ok" != "1" ]; then notify_deploy_failure "deploy aborted (exit $rc)"; fi' EXIT
|
||||
_ok=0
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "=== oikos-web deploy: $(date) ==="
|
||||
|
||||
# Resolve the SHA we are ABOUT to deploy from the remote (read-only) so the
|
||||
# CI gate can run before anything is touched.
|
||||
REMOTE_FULL=$(git ls-remote origin refs/heads/main 2>/dev/null | awk '{print $1}')
|
||||
if [ -z "$REMOTE_FULL" ]; then
|
||||
echo "ERROR: could not resolve origin/main (offline?) — aborting before any change"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE_SHA=$(printf '%s' "$REMOTE_FULL" | cut -c1-12)
|
||||
echo "remote SHA: $REMOTE_SHA"
|
||||
|
||||
# ── 1. CI gate (D1) ──────────────────────────────────────────────────────
|
||||
echo "[1/6] verify CI status for $REMOTE_SHA"
|
||||
verify_ci() {
|
||||
sha=$1
|
||||
if [ -z "$GITEA_URL" ] || [ -z "$GITEA_TOKEN" ]; then
|
||||
echo "SKIP: GITEA_URL/GITEA_TOKEN not set — CI gate disabled. Set both to enforce."
|
||||
return 0
|
||||
fi
|
||||
origin=$(git remote get-url origin 2>/dev/null || echo "")
|
||||
seg=
|
||||
case "$origin" in
|
||||
*@*:*) seg=${origin##*:}; seg=${seg%.git} ;;
|
||||
http://*|https://*) seg=${origin#*://}; seg=${seg#*/}; seg=${seg%.git} ;;
|
||||
esac
|
||||
case "$seg" in
|
||||
*/*) GITEA_OWNER=${seg%%/*}; GITEA_REPO=${seg#*/} ;;
|
||||
esac
|
||||
|
||||
api="$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/commits/$sha/status"
|
||||
body=$(mktemp)
|
||||
elapsed=0
|
||||
saw_ci=0
|
||||
no_signal=0
|
||||
while [ "$elapsed" -lt "$CI_TIMEOUT" ]; do
|
||||
code=$(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" | \
|
||||
curl -sS -o "$body" -w '%{http_code}' --config - "$api" 2>/dev/null) || code="000"
|
||||
state=$(sed -n 's/.*"state"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$body" | head -n1)
|
||||
|
||||
case "$code" in
|
||||
200)
|
||||
case "$state" in
|
||||
success)
|
||||
rm -f "$body"
|
||||
echo "CI: green for $sha after ${elapsed}s"
|
||||
return 0
|
||||
;;
|
||||
failure|error)
|
||||
rm -f "$body"
|
||||
echo "ERROR: CI $state for $sha — refusing to deploy."
|
||||
echo " See $GITEA_URL/$GITEA_OWNER/$GITEA_REPO/actions"
|
||||
return 1
|
||||
;;
|
||||
pending|"")
|
||||
saw_ci=1
|
||||
no_signal=0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
404)
|
||||
rm -f "$body"
|
||||
echo "WARN: Gitea has no CI status for $sha (404)."
|
||||
echo " Is Gitea Actions configured with a runner for $GITEA_OWNER/$GITEA_REPO?"
|
||||
echo " Proceeding without a gate."
|
||||
return 0
|
||||
;;
|
||||
401|403)
|
||||
rm -f "$body"
|
||||
echo "WARN: GITEA_TOKEN rejected by Gitea ($code) — cannot verify CI."
|
||||
echo " Fix the token to enforce the gate; proceeding without one."
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
no_signal=$((no_signal + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$saw_ci" -eq 0 ] && [ "$no_signal" -ge 4 ]; then
|
||||
rm -f "$body"
|
||||
echo "WARN: no CI signal from Gitea after ${elapsed}s (last code=$code)."
|
||||
echo " CI may be down or misconfigured; proceeding without a gate."
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep "$CI_POLL_INTERVAL"
|
||||
elapsed=$((elapsed + CI_POLL_INTERVAL))
|
||||
printf '\rCI: waiting (%ss, code=%s state=%s)...' "$elapsed" "$code" "${state:-none}"
|
||||
done
|
||||
rm -f "$body"
|
||||
echo ""
|
||||
echo "ERROR: CI did not reach a terminal state within ${CI_TIMEOUT}s for $sha — refusing to deploy."
|
||||
return 1
|
||||
}
|
||||
verify_ci "$REMOTE_SHA" || exit 1
|
||||
|
||||
# ── 2. Update working tree to the verified commit ────────────────────────
|
||||
echo "[2/6] git pull (ff-only)"
|
||||
git pull --ff-only origin main
|
||||
# TOCTOU guard: origin/main may have advanced during the CI wait; refuse to
|
||||
# ship an unverified commit — a retry verifies the new tip.
|
||||
PULLED_FULL=$(git rev-parse HEAD)
|
||||
if [ "$PULLED_FULL" != "$REMOTE_FULL" ]; then
|
||||
echo "ERROR: origin/main advanced during deploy (verified $REMOTE_SHA, now at $(git rev-parse --short HEAD)) — aborting; retry verifies the new tip"
|
||||
exit 1
|
||||
fi
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
echo "SHA (deployed): $SHA"
|
||||
|
||||
# Resolve the deploy version AFTER pull (D2) so the tag matches the code
|
||||
# being built. Compose interpolates $OIKOS_VERSION into the image: tag.
|
||||
VERSION_FILE="$REPO_DIR/VERSION"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
OIKOS_VERSION="v$(head -n1 "$VERSION_FILE" | tr -d '[:space:]')"
|
||||
export OIKOS_VERSION
|
||||
echo "VERSION: $OIKOS_VERSION"
|
||||
else
|
||||
echo "WARNING: VERSION file missing — image will use :latest (rollback unavailable)"
|
||||
fi
|
||||
|
||||
# ── 3. Build version-tagged image (D2) ───────────────────────────────────
|
||||
echo "[3/6] docker compose build"
|
||||
DOCKER_BUILDKIT=1 docker compose -f "$COMPOSE_FILE" build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
# ── 4. Rolling restart ───────────────────────────────────────────────────
|
||||
echo "[4/6] docker compose up -d"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||
|
||||
# ── 5. Prune old image tags — keep the 3 newest so rollback ──────────────
|
||||
# (OIKOS_VERSION=v0.x.y docker compose up) stays available.
|
||||
echo "[5/6] prune old image tags (keep 3)"
|
||||
if [ -n "$OIKOS_VERSION" ]; then
|
||||
images=$(docker compose -f "$COMPOSE_FILE" config --images 2>/dev/null || true)
|
||||
if [ -z "$images" ]; then
|
||||
images="oikos-web"
|
||||
fi
|
||||
printf '%s\n' $images | sed 's/:.*//' | sort -u | while read -r repo; do
|
||||
docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do
|
||||
docker rmi "$repo:$tag" >/dev/null 2>&1 || true
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 6. Health check wait (SPA answers on the published port) ─────────────
|
||||
echo "[6/6] health check"
|
||||
healthy=0
|
||||
for i in $(seq 1 $RETRIES); do
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
echo "healthy after ${i}x${SLEEP}s"
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
sleep "$SLEEP"
|
||||
done
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
echo "ERROR: health check failed after $((RETRIES * SLEEP))s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All steps completed successfully — clear failure trap
|
||||
_ok=1
|
||||
exit 0
|
||||
86
scripts/install-webhook.sh
Executable file
86
scripts/install-webhook.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/sh
|
||||
# Install the oikos-web deploy-webhook launchd unit on the mac-mini.
|
||||
#
|
||||
# Renders scripts/oikos-web-deploy-webhook.plist with real secret values and
|
||||
# loads it. Values come from env; when absent, the HMAC secret and API token
|
||||
# are resolved from the oikos stack's Infisical via the oikos CLI (only
|
||||
# available on the mac-mini with the oikos checkout + .env).
|
||||
#
|
||||
# Usage:
|
||||
# WEBHOOK_HMAC_SECRET=... GITEA_TOKEN=... ./scripts/install-webhook.sh
|
||||
set -e
|
||||
|
||||
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PLIST_DST="$HOME/Library/LaunchAgents/network.hubris.oikos-web-deploy-webhook.plist"
|
||||
LABEL="network.hubris.oikos-web-deploy-webhook"
|
||||
|
||||
OIKOS_CLI="${OIKOS_CLI:-$HOME/Projects/oikos/oikos}"
|
||||
infisical_get() {
|
||||
[ -x "$OIKOS_CLI" ] || return 1
|
||||
(cd "$HOME/Projects/oikos" && set -a && . ./.env 2>/dev/null && set +a \
|
||||
&& OIKOS_INFISICAL_SITE_URL=http://localhost:8080 "$OIKOS_CLI" secret get "$1" 2>/dev/null) \
|
||||
| grep -E '^[0-9a-f]{40,}$' | head -n1
|
||||
}
|
||||
|
||||
HMAC="${WEBHOOK_HMAC_SECRET:-$(infisical_get webhook_hmac-secret || true)}"
|
||||
GITEA_TOKEN="${GITEA_TOKEN:-$(infisical_get gitea-pat_token || true)}"
|
||||
API_TOKEN="${OIKOS_API_TOKEN:-$(infisical_get api_token || true)}"
|
||||
|
||||
if [ -z "$HMAC" ]; then
|
||||
echo "ERROR: WEBHOOK_HMAC_SECRET not set and could not resolve from Infisical" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the webhook binary first
|
||||
(cd "$REPO_DIR" && make webhook)
|
||||
|
||||
launchctl bootout "gui/$(id -u)" "$PLIST_DST" 2>/dev/null || true
|
||||
|
||||
cat > "$PLIST_DST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$REPO_DIR/webhook</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$REPO_DIR</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>$HOME</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
<key>WEBHOOK_LISTEN</key>
|
||||
<string>:9798</string>
|
||||
<key>WEBHOOK_REPO_DIR</key>
|
||||
<string>$REPO_DIR</string>
|
||||
<key>WEBHOOK_HMAC_SECRET</key>
|
||||
<string>$HMAC</string>
|
||||
<key>GITEA_URL</key>
|
||||
<string>https://git.hubris.network</string>
|
||||
<key>GITEA_TOKEN</key>
|
||||
<string>$GITEA_TOKEN</string>
|
||||
<key>OIKOS_API_TOKEN</key>
|
||||
<string>$API_TOKEN</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$HOME/Library/Logs/oikos-web-webhook.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$HOME/Library/Logs/oikos-web-webhook.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
chmod 600 "$PLIST_DST"
|
||||
|
||||
launchctl bootstrap "gui/$(id -u)" "$PLIST_DST"
|
||||
sleep 1
|
||||
launchctl print "gui/$(id -u)/$LABEL" >/dev/null && echo "installed: $LABEL (listening :9798)"
|
||||
38
scripts/oikos-web-deploy-webhook.plist
Normal file
38
scripts/oikos-web-deploy-webhook.plist
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>oikos-web-deploy-webhook</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/dtoro/Projects/oikos-web/webhook</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>WEBHOOK_LISTEN</key>
|
||||
<string>:9798</string>
|
||||
<key>WEBHOOK_REPO_DIR</key>
|
||||
<string>/Users/dtoro/Projects/oikos-web</string>
|
||||
<!-- Rendered by install-webhook.sh at install time (same values as
|
||||
the oikos stack: HMAC from Infisical webhook_hmac-secret, Gitea
|
||||
PAT for the CI gate, Oikos API token for failure events). -->
|
||||
<key>WEBHOOK_HMAC_SECRET</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
<key>GITEA_URL</key>
|
||||
<string>https://git.hubris.network</string>
|
||||
<key>GITEA_TOKEN</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
<key>OIKOS_API_TOKEN</key>
|
||||
<string>SET_AT_INSTALL</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/oikos-web-deploy-webhook.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/oikos-web-deploy-webhook.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user