Updater: /status endpoint (Forgejo vs deployed commit), /deploy/:repo manual trigger
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e1c57a4ec
commit
5984043e7c
2 changed files with 96 additions and 22 deletions
|
|
@ -18,6 +18,9 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
- SSH_KEY_PATH=/root/.ssh/id_ed25519
|
- 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:
|
volumes:
|
||||||
- /root/.ssh:/root/.ssh:ro
|
- /root/.ssh:/root/.ssh:ro
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,70 @@ import { deployMap } from '../deploy-map.js'
|
||||||
const app = Fastify({ logger: true })
|
const app = Fastify({ logger: true })
|
||||||
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || ''
|
||||||
const SSH_KEY = process.env.SSH_KEY_PATH || '/root/.ssh/id_ed25519'
|
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 deployLog = []
|
||||||
|
const statusCache = {}
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000
|
||||||
|
|
||||||
function verifySignature(body, signature) {
|
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')}`
|
const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}`
|
||||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''))
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deploy(target, repoName) {
|
function sshGet(host, cmd) {
|
||||||
const { host, path } = target
|
try {
|
||||||
const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${host} "cd ${path} && git pull && docker compose up -d --build"`
|
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)
|
deployLog.unshift(entry)
|
||||||
if (deployLog.length > 50) deployLog.pop()
|
if (deployLog.length > 50) deployLog.pop()
|
||||||
|
|
||||||
|
|
@ -27,23 +78,51 @@ async function deploy(target, repoName) {
|
||||||
entry.status = 'success'
|
entry.status = 'success'
|
||||||
entry.output = out.slice(-500)
|
entry.output = out.slice(-500)
|
||||||
app.log.info(`Deploy ${repoName} → success`)
|
app.log.info(`Deploy ${repoName} → success`)
|
||||||
|
delete statusCache[repoName] // force re-check after deploy
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.status = 'failed'
|
entry.status = 'failed'
|
||||||
entry.error = err.message.slice(-500)
|
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()
|
entry.finished = new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/webhook', {
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
config: { rawBody: true }
|
|
||||||
}, async (request, reply) => {
|
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 sig = request.headers['x-hub-signature-256']
|
||||||
const rawBody = request.rawBody || JSON.stringify(request.body)
|
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']
|
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' })
|
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]
|
const target = deployMap[repoName]
|
||||||
if (!target) {
|
if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' })
|
||||||
app.log.warn(`No deploy target for repo: ${repoName}`)
|
|
||||||
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(() => {})
|
deploy(target, repoName).catch(() => {})
|
||||||
return reply.status(202).send({ ok: true, deploying: repoName })
|
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' })
|
await app.listen({ port: 9000, host: '0.0.0.0' })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue