diff --git a/vps/deploy.sh b/vps/deploy.sh new file mode 100644 index 0000000..f5c58af --- /dev/null +++ b/vps/deploy.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# ============================================================================= +# /opt/timmy-tower/deploy.sh +# Pull latest from Hermes Gitea, build, deploy, health-check, rollback on fail. +# Invoked by the webhook receiver (webhook.js) or manually by an operator. +# ============================================================================= +set -euo pipefail + +DEPLOY_DIR="/opt/timmy-tower" +WORK_DIR="/tmp/timmy-deploy-$$" +LOG="$DEPLOY_DIR/deploy.log" +TOKEN_FILE="/root/.gitea-replit-token" +GITEA_HOST="143.198.27.163:3000" +GITEA_REPO="admin/timmy-tower" +HEALTH_URL="http://localhost:8088/api/health" + +log() { echo "[$(date -u +%FT%TZ)] [deploy] $*" | tee -a "$LOG"; } +fail() { log "ERROR: $*"; exit 1; } + +log "========================================" +log "Deploy started" + +# ── 1. Backup current bundle ────────────────────────────────────────────────── +if [ -f "$DEPLOY_DIR/index.js" ]; then + cp "$DEPLOY_DIR/index.js" "$DEPLOY_DIR/index.js.bak" + log "Previous bundle backed up → index.js.bak" +fi + +# ── 2. Clone repo from Hermes Gitea ────────────────────────────────────────── +TOKEN=$(tr -d '[:space:]' < "$TOKEN_FILE") +log "Cloning repo from Hermes Gitea..." +git clone --depth=1 \ + "http://admin:${TOKEN}@${GITEA_HOST}/${GITEA_REPO}.git" \ + "$WORK_DIR" 2>&1 | tail -3 | tee -a "$LOG" + +# ── 3. Install deps + build ─────────────────────────────────────────────────── +cd "$WORK_DIR" + +log "Ensuring pnpm is available..." +if ! command -v pnpm &>/dev/null; then + corepack enable + corepack prepare pnpm@latest --activate +fi + +log "Installing dependencies..." +pnpm install --frozen-lockfile 2>&1 | tail -5 | tee -a "$LOG" \ + || fail "pnpm install failed" + +log "Building api-server..." +pnpm --filter @workspace/api-server run build 2>&1 | tail -15 | tee -a "$LOG" \ + || fail "build failed" + +# ── 4. Deploy bundle ────────────────────────────────────────────────────────── +BUNDLE="$WORK_DIR/artifacts/api-server/dist/index.js" +[ -f "$BUNDLE" ] || fail "Bundle not found at $BUNDLE after build" + +cp "$BUNDLE" "$DEPLOY_DIR/index.js" +log "Bundle deployed." + +# ── 5. Restart service ──────────────────────────────────────────────────────── +log "Restarting timmy-tower..." +systemctl restart timmy-tower +sleep 6 + +# ── 6. Health check — rollback on failure ──────────────────────────────────── +if curl -sf --max-time 15 "$HEALTH_URL" > /dev/null; then + log "Health check passed ✓" +else + log "Health check FAILED — rolling back" + if [ -f "$DEPLOY_DIR/index.js.bak" ]; then + cp "$DEPLOY_DIR/index.js.bak" "$DEPLOY_DIR/index.js" + systemctl restart timmy-tower + log "Rollback complete. Previous bundle restored." + else + log "No backup available — service may be down" + fi + cd /; rm -rf "$WORK_DIR" + exit 1 +fi + +# ── 7. Cleanup ──────────────────────────────────────────────────────────────── +cd / +rm -rf "$WORK_DIR" +log "Deploy complete" +log "========================================" diff --git a/vps/health-check.sh b/vps/health-check.sh new file mode 100644 index 0000000..32e3787 --- /dev/null +++ b/vps/health-check.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# ============================================================================= +# /opt/timmy-tower/health-check.sh +# Run by systemd timer every 5 minutes. +# Restarts timmy-tower if /api/health returns non-200. +# ============================================================================= +HEALTH_URL="http://localhost:8088/api/health" +LOG="/opt/timmy-tower/health.log" + +log() { echo "[$(date -u +%FT%TZ)] [health] $*" | tee -a "$LOG"; } + +if curl -sf --max-time 10 "$HEALTH_URL" > /dev/null; then + exit 0 +fi + +log "Health check FAILED — restarting timmy-tower" +systemctl restart timmy-tower +sleep 5 + +if curl -sf --max-time 10 "$HEALTH_URL" > /dev/null; then + log "Service recovered after restart" +else + log "CRITICAL: service did not recover after restart — manual intervention needed" +fi diff --git a/vps/timmy-deploy-hook.service b/vps/timmy-deploy-hook.service new file mode 100644 index 0000000..bf5c705 --- /dev/null +++ b/vps/timmy-deploy-hook.service @@ -0,0 +1,18 @@ +[Unit] +Description=Timmy Tower Deploy Webhook Receiver +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/timmy-tower +ExecStart=/usr/bin/node /opt/timmy-tower/webhook.js +Restart=always +RestartSec=5 +EnvironmentFile=/opt/timmy-tower/.env +StandardOutput=journal +StandardError=journal +SyslogIdentifier=timmy-deploy-hook + +[Install] +WantedBy=multi-user.target diff --git a/vps/timmy-health.service b/vps/timmy-health.service new file mode 100644 index 0000000..9355606 --- /dev/null +++ b/vps/timmy-health.service @@ -0,0 +1,11 @@ +[Unit] +Description=Timmy Tower Health Check (one-shot) +After=network.target + +[Service] +Type=oneshot +User=root +ExecStart=/opt/timmy-tower/health-check.sh +StandardOutput=journal +StandardError=journal +SyslogIdentifier=timmy-health diff --git a/vps/timmy-health.timer b/vps/timmy-health.timer new file mode 100644 index 0000000..0bbc087 --- /dev/null +++ b/vps/timmy-health.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Timmy Tower Health Check — every 5 minutes + +[Timer] +OnBootSec=60 +OnUnitActiveSec=5min +Unit=timmy-health.service + +[Install] +WantedBy=timers.target diff --git a/vps/webhook.js b/vps/webhook.js new file mode 100644 index 0000000..e81a9f5 --- /dev/null +++ b/vps/webhook.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// ============================================================================= +// /opt/timmy-tower/webhook.js +// Lightweight webhook receiver: validates Gitea HMAC signature, runs deploy.sh +// Runs as systemd service: timmy-deploy-hook +// ============================================================================= +'use strict'; + +const http = require('http'); +const crypto = require('crypto'); +const { execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const PORT = 9000; +const HOST = '127.0.0.1'; +const SECRET = process.env.WEBHOOK_SECRET || ''; +const DEPLOY_SCRIPT = path.join(__dirname, 'deploy.sh'); +const LOG_FILE = path.join(__dirname, 'deploy.log'); + +let deploying = false; + +function log(msg) { + const line = `[${new Date().toISOString()}] [webhook] ${msg}\n`; + process.stdout.write(line); + try { fs.appendFileSync(LOG_FILE, line); } catch (_) {} +} + +function verifySignature(payload, signature) { + if (!SECRET) { + log('WARN: WEBHOOK_SECRET not set — skipping signature check'); + return true; + } + const hmac = crypto.createHmac('sha256', SECRET); + hmac.update(payload); + const expected = 'sha256=' + hmac.digest('hex'); + try { + return crypto.timingSafeEqual( + Buffer.from(expected, 'utf8'), + Buffer.from(signature, 'utf8') + ); + } catch (_) { + return false; + } +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, deploying })); + return; + } + + if (req.method !== 'POST' || req.url !== '/deploy') { + res.writeHead(404).end('Not found'); + return; + } + + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks); + const sig = req.headers['x-gitea-signature'] + || req.headers['x-hub-signature-256'] + || ''; + + if (!verifySignature(body, sig)) { + log(`Rejected deploy: invalid HMAC signature from ${req.socket.remoteAddress}`); + res.writeHead(401).end('Unauthorized'); + return; + } + + let payload = {}; + try { payload = JSON.parse(body.toString()); } catch (_) {} + + const branch = (payload.ref || '').replace('refs/heads/', ''); + if (branch && branch !== 'main') { + log(`Skipping push to non-main branch: ${branch}`); + res.writeHead(200).end('Skipped'); + return; + } + + if (deploying) { + log('Deploy already in progress — request queued'); + res.writeHead(202).end('Deploy already in progress'); + return; + } + + const pusher = payload.pusher ? payload.pusher.login || payload.pusher.name : 'unknown'; + const commit = payload.after ? payload.after.slice(0, 8) : 'unknown'; + log(`Deploy triggered — pusher: ${pusher}, commit: ${commit}`); + + res.writeHead(202).end('Deploy started'); + + deploying = true; + execFile('bash', [DEPLOY_SCRIPT], { timeout: 600_000 }, (err, stdout, stderr) => { + deploying = false; + if (err) { + log(`Deploy FAILED: ${err.message}`); + } else { + log(`Deploy succeeded (commit ${commit})`); + } + }); + }); +}); + +server.listen(PORT, HOST, () => { + log(`Webhook receiver listening on ${HOST}:${PORT}`); + log(`Deploy script: ${DEPLOY_SCRIPT}`); + log(`HMAC secret: ${SECRET ? 'configured' : 'NOT SET (insecure)'}`); +}); + +process.on('SIGTERM', () => { log('SIGTERM received — shutting down'); process.exit(0); }); +process.on('SIGINT', () => { log('SIGINT received — shutting down'); process.exit(0); });