import Fastify from 'fastify' import crypto from 'crypto' import { exec, execSync } from 'child_process' import { promisify } from 'util' import { deployMap } from '../deploy-map.js' const execAsync = promisify(exec) const app = Fastify({ logger: true }) const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || '' const SSH_KEY = process.env.SSH_KEY_PATH || '/root/.ssh/id_ed25519' const FORGEJO_URL = process.env.FORGEJO_URL || 'https://git.pterois.co.uk' const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack' const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || '' const deployLog = [] const statusCache = {} const CACHE_TTL = 5 * 60 * 1000 const healthMap = { 'portal': { host: '10.10.10.102', port: 3000 }, 'auth': { host: '10.10.10.101', port: 3001 }, 'noticeboard': { host: '10.10.10.112', port: 3080 }, 'kitchen': { host: '10.10.10.110', port: 3080 }, 'cashup': { host: '10.10.10.111', port: 3080 }, 'housekeeping': { host: '10.10.10.114', port: 3080 }, 'forecasting': { host: '10.10.10.113', port: 3080 }, 'rates': { host: '10.10.10.115', port: 3080 }, } function verifySignature(body, signature) { if (!WEBHOOK_SECRET) return true const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}` return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || '')) } async function sshGet(host, cmd) { try { const { stdout } = await execAsync( `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=2 root@${host} "${cmd}"`, { timeout: 8_000 } ) return stdout.trim() } catch { return null } } async function getForgejoCommit(repo) { try { const headers = { Accept: 'application/json' } if (FORGEJO_TOKEN) headers['Authorization'] = `token ${FORGEJO_TOKEN}` const res = await fetch(`${FORGEJO_URL}/api/v1/repos/${FORGEJO_ORG}/${repo}/commits?limit=1`, { headers }) if (!res.ok) return null const data = await res.json() return data?.[0]?.sha || null } catch { return null } } async function refreshStatus(repo) { const target = deployMap[repo] if (!target) return null const [current, latest] = await Promise.all([ sshGet(target.host, `git -C ${target.path} rev-parse HEAD 2>/dev/null || echo not-deployed`), getForgejoCommit(repo), ]) const deployed = current && current !== 'not-deployed' const entry = { repo, host: target.host, currentCommit: deployed ? current.slice(0, 12) : (current || 'unreachable'), latestCommit: latest ? latest.slice(0, 12) : 'unknown', updateAvailable: !!(deployed && latest && current && latest !== current), deployed, checkedAt: new Date().toISOString(), } statusCache[repo] = entry return entry } async function deploy(target, repoName) { const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${target.host} "cd ${target.path} && git pull && docker compose up -d --build"` const entry = { repo: repoName, host: target.host, started: new Date().toISOString(), status: 'running' } deployLog.unshift(entry) if (deployLog.length > 50) deployLog.pop() try { const out = execSync(cmd, { timeout: 300_000, encoding: 'utf8' }) entry.status = 'success' entry.output = out.slice(-500) app.log.info(`Deploy ${repoName} → success`) delete statusCache[repoName] // force re-check after deploy } catch (err) { entry.status = 'failed' entry.error = err.message.slice(-500) app.log.error(`Deploy ${repoName} → failed`) } entry.finished = new Date().toISOString() } // ── Routes ──────────────────────────────────────────────────────────────────── app.get('/health', async () => ({ status: 'healthy' })) app.get('/health-status', async () => { const results = await Promise.all( Object.entries(healthMap).map(async ([name, { host, port }]) => { const t0 = Date.now() try { await fetch(`http://${host}:${port}/health`, { signal: AbortSignal.timeout(3000), }) return { name, up: true, ms: Date.now() - t0 } } catch { return { name, up: false, ms: null } } }) ) return results }) app.get('/deploys', async () => deployLog) app.get('/status', async (request) => { const single = request.query?.repo const repos = single ? [single] : Object.keys(deployMap) const now = Date.now() const results = await Promise.all(repos.map(repo => { const cached = statusCache[repo] if (cached && (now - new Date(cached.checkedAt).getTime()) < CACHE_TTL) return cached return refreshStatus(repo) })) return results.filter(Boolean) }) // Manual deploy — no webhook signature needed, protected by portal admin auth app.post('/deploy/:repo', async (request, reply) => { const { repo } = request.params const target = deployMap[repo] if (!target) return reply.status(404).send({ error: 'Unknown repo' }) const running = deployLog.find(d => d.repo === repo && d.status === 'running') if (running) return reply.status(409).send({ error: 'Deploy already in progress' }) deploy(target, repo).catch(() => {}) return reply.status(202).send({ ok: true, deploying: repo }) }) // Forgejo webhook — auto-deploy on push to main app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => { const sig = request.headers['x-hub-signature-256'] const rawBody = request.rawBody || JSON.stringify(request.body) if (!verifySignature(rawBody, sig)) return reply.status(401).send({ error: 'Invalid signature' }) const event = request.headers['x-gitea-event'] || request.headers['x-github-event'] if (event !== 'push') return reply.status(200).send({ ok: true, skipped: 'not a push event' }) const repoName = request.body?.repository?.name const ref = request.body?.ref || '' if (!ref.endsWith('/main') && !ref.endsWith('/master')) { return reply.status(200).send({ ok: true, skipped: 'not main branch' }) } const target = deployMap[repoName] if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' }) deploy(target, repoName).catch(() => {}) return reply.status(202).send({ ok: true, deploying: repoName }) }) await app.listen({ port: 9000, host: '0.0.0.0' })