management/updater/src/index.js
jtricerolph dae5524dbc Fix health check treating 404 as unreachable
Apps without a /health route return 404 from nginx, but the service IS
running. Treat any HTTP response as up; only connection errors are down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:43:32 +00:00

175 lines
6.3 KiB
JavaScript

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' })