diff --git a/docker-compose.yml b/docker-compose.yml index c2693a3..4bcf5cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,9 @@ services: environment: - WEBHOOK_SECRET=${WEBHOOK_SECRET} - SSH_KEY_PATH=/root/.ssh/id_ed25519 + - FORGEJO_URL=${FORGEJO_URL:-https://git.pterois.co.uk} + - FORGEJO_ORG=${FORGEJO_ORG:-hotel-manage-stack} + - FORGEJO_TOKEN=${FORGEJO_TOKEN:-} volumes: - /root/.ssh:/root/.ssh:ro ports: diff --git a/updater/src/index.js b/updater/src/index.js index baf4e2f..88469b6 100644 --- a/updater/src/index.js +++ b/updater/src/index.js @@ -6,19 +6,70 @@ import { deployMap } from '../deploy-map.js' 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 function verifySignature(body, signature) { - if (!WEBHOOK_SECRET) return true // disabled in dev + 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 deploy(target, repoName) { - const { host, path } = target - const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${host} "cd ${path} && git pull && docker compose up -d --build"` +function sshGet(host, cmd) { + try { + return execSync( + `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@${host} "${cmd}"`, + { timeout: 15_000, encoding: 'utf8' } + ).trim() + } catch { + return null + } +} - const entry = { repo: repoName, host, started: new Date().toISOString(), status: 'running' } +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([ + Promise.resolve(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() @@ -27,23 +78,51 @@ async function deploy(target, repoName) { 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: ${err.message}`) + app.log.error(`Deploy ${repoName} → failed`) } entry.finished = new Date().toISOString() } -app.post('/webhook', { - config: { rawBody: true } -}, async (request, reply) => { +// ── Routes ──────────────────────────────────────────────────────────────────── + +app.get('/health', async () => ({ status: 'healthy' })) + +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' }) - } + 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' }) @@ -55,18 +134,10 @@ app.post('/webhook', { } const target = deployMap[repoName] - if (!target) { - app.log.warn(`No deploy target for repo: ${repoName}`) - return reply.status(200).send({ ok: true, skipped: 'no deploy target' }) - } + if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' }) - // Kick off deploy async — don't block the webhook response deploy(target, repoName).catch(() => {}) return reply.status(202).send({ ok: true, deploying: repoName }) }) -app.get('/health', async () => ({ status: 'healthy' })) - -app.get('/deploys', async () => deployLog) - await app.listen({ port: 9000, host: '0.0.0.0' })