Replace static deploy-map with dynamic auth registry

updater now fetches app registry from auth /api/auth/internal/registry
and uses internal_host/internal_port for SSH status checks and deploys.
Removes hardcoded host map; platform infra (auth, portal, settings)
kept in INFRA_DEPLOY/INFRA_HEALTH constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 20:49:39 +00:00
parent b120fbf44e
commit c26f7e18b2
3 changed files with 83 additions and 46 deletions

View file

@ -1,4 +1,6 @@
WEBHOOK_SECRET=CHANGE_ME_match_forgejo_webhook_secret WEBHOOK_SECRET=CHANGE_ME_match_forgejo_webhook_secret
CENTRAL_AUTH_SECRET=CHANGE_ME_same_as_auth_service
AUTH_URL=http://10.10.10.101:3001
PG_SUPERPASS=CHANGE_ME_postgres_superuser_password PG_SUPERPASS=CHANGE_ME_postgres_superuser_password
BACKUP_DATABASES=auth_db noticeboard_db kitchen_db cashup_db hk_db BACKUP_DATABASES=auth_db noticeboard_db kitchen_db cashup_db hk_db
BACKUP_REMOTE=user@yourserver.com:/backups/hnf-proxmox BACKUP_REMOTE=user@yourserver.com:/backups/hnf-proxmox

View file

@ -1,16 +1,3 @@
// Maps Forgejo repo names → target LXC SSH details. // Deploy targets are now fetched dynamically from the auth registry.
// Keys are the plain repo names Forgejo sends in the webhook (repository.name). // See src/index.js — INFRA_DEPLOY for platform services, appRegistry for user apps.
// The updater only pulls + rebuilds existing LXCs — provisioning is done on the // This file is no longer imported.
// Proxmox host with proxmox-helpers/add-app.sh, which appends new apps here.
export const deployMap = {
'noticeboard': { host: '10.10.10.112', path: '/opt/noticeboard' },
'kitchen': { host: '10.10.10.110', path: '/opt/kitchen' },
'cashup': { host: '10.10.10.111', path: '/opt/cashup' },
'housekeeping': { host: '10.10.10.114', path: '/opt/housekeeping' },
'forecasting': { host: '10.10.10.113', path: '/opt/forecasting' },
'rates': { host: '10.10.10.115', path: '/opt/rates' },
'portal': { host: '10.10.10.102', path: '/opt/portal' },
'auth': { host: '10.10.10.101', path: '/opt/auth' },
'settings': { host: '10.10.10.116', path: '/opt/settings' },
// 'management' intentionally omitted — can't redeploy the container managing deploys
}

View file

@ -2,33 +2,77 @@ import Fastify from 'fastify'
import crypto from 'crypto' import crypto from 'crypto'
import { exec, execSync } from 'child_process' import { exec, execSync } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { deployMap } from '../deploy-map.js'
const execAsync = promisify(exec) const execAsync = promisify(exec)
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_URL = process.env.FORGEJO_URL || 'https://git.pterois.co.uk'
const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack' const FORGEJO_ORG = process.env.FORGEJO_ORG || 'hotel-manage-stack'
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || '' const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || ''
const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001'
const CENTRAL_AUTH_SECRET = process.env.CENTRAL_AUTH_SECRET || ''
// Platform services: fixed IPs, never in the user-facing apps table.
// 'management' excluded from deploy — can't redeploy the container managing deploys.
const INFRA_HEALTH = {
portal: { host: '10.10.10.102', port: 3000 },
auth: { host: '10.10.10.101', port: 3001 },
settings: { host: '10.10.10.116', port: 3080 },
}
const INFRA_DEPLOY = {
portal: { host: '10.10.10.102', path: '/opt/portal' },
auth: { host: '10.10.10.101', path: '/opt/auth' },
settings: { host: '10.10.10.116', path: '/opt/settings' },
}
// App registry: fetched dynamically from auth — slug → { host, port, path }
let appRegistry = {}
async function refreshRegistry() {
try {
const res = await fetch(`${AUTH_URL}/api/auth/internal/registry`, {
headers: { Authorization: `Bearer ${CENTRAL_AUTH_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) { app.log.warn(`Registry fetch ${res.status}`); return }
const apps = await res.json()
const next = {}
for (const a of apps) {
if (a.internal_host && a.internal_port) {
next[a.slug] = { host: a.internal_host, port: a.internal_port, path: `/opt/${a.slug}` }
}
}
appRegistry = next
app.log.info(`Registry refreshed: ${Object.keys(next).join(', ') || '(empty)'}`)
} catch (e) {
app.log.warn(`Registry refresh failed: ${e.message}`)
}
}
function getHealthMap() {
const map = { ...INFRA_HEALTH }
for (const [slug, { host, port }] of Object.entries(appRegistry)) map[slug] = { host, port }
return map
}
function getDeployTarget(repo) {
if (INFRA_DEPLOY[repo]) return INFRA_DEPLOY[repo]
const a = appRegistry[repo]
return a ? { host: a.host, path: a.path } : null
}
function allDeployRepos() {
return [...Object.keys(INFRA_DEPLOY), ...Object.keys(appRegistry)]
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const deployLog = [] const deployLog = []
const statusCache = {} const statusCache = {}
const CACHE_TTL = 5 * 60 * 1000 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 },
'settings': { host: '10.10.10.116', port: 3080 },
}
function verifySignature(body, signature) { function verifySignature(body, signature) {
if (!WEBHOOK_SECRET) return true 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')}`
@ -61,7 +105,7 @@ async function getForgejoCommit(repo) {
} }
async function refreshStatus(repo) { async function refreshStatus(repo) {
const target = deployMap[repo] const target = getDeployTarget(repo)
if (!target) return null if (!target) return null
const [current, latest] = await Promise.all([ const [current, latest] = await Promise.all([
@ -94,7 +138,7 @@ 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 delete statusCache[repoName]
} catch (err) { } catch (err) {
entry.status = 'failed' entry.status = 'failed'
entry.error = err.message.slice(-500) entry.error = err.message.slice(-500)
@ -108,20 +152,18 @@ async function deploy(target, repoName) {
app.get('/health', async () => ({ status: 'healthy' })) app.get('/health', async () => ({ status: 'healthy' }))
app.get('/health-status', async () => { app.get('/health-status', async () => {
const results = await Promise.all( const healthMap = getHealthMap()
return Promise.all(
Object.entries(healthMap).map(async ([name, { host, port }]) => { Object.entries(healthMap).map(async ([name, { host, port }]) => {
const t0 = Date.now() const t0 = Date.now()
try { try {
await fetch(`http://${host}:${port}/health`, { await fetch(`http://${host}:${port}/health`, { signal: AbortSignal.timeout(3000) })
signal: AbortSignal.timeout(3000),
})
return { name, up: true, ms: Date.now() - t0 } return { name, up: true, ms: Date.now() - t0 }
} catch { } catch {
return { name, up: false, ms: null } return { name, up: false, ms: null }
} }
}) })
) )
return results
}) })
app.get('/deploys', async () => deployLog) app.get('/deploys', async () => deployLog)
@ -129,7 +171,7 @@ app.get('/deploys', async () => deployLog)
app.get('/status', async (request) => { app.get('/status', async (request) => {
const single = request.query?.repo const single = request.query?.repo
const force = request.query?.force === 'true' const force = request.query?.force === 'true'
const repos = single ? [single] : Object.keys(deployMap) const repos = single ? [single] : allDeployRepos()
const now = Date.now() const now = Date.now()
const results = await Promise.all(repos.map(repo => { const results = await Promise.all(repos.map(repo => {
const cached = statusCache[repo] const cached = statusCache[repo]
@ -139,10 +181,9 @@ app.get('/status', async (request) => {
return results.filter(Boolean) return results.filter(Boolean)
}) })
// Manual deploy — no webhook signature needed, protected by portal admin auth
app.post('/deploy/:repo', async (request, reply) => { app.post('/deploy/:repo', async (request, reply) => {
const { repo } = request.params const { repo } = request.params
const target = deployMap[repo] const target = getDeployTarget(repo)
if (!target) return reply.status(404).send({ error: 'Unknown repo' }) if (!target) return reply.status(404).send({ error: 'Unknown repo' })
const running = deployLog.find(d => d.repo === repo && d.status === 'running') const running = deployLog.find(d => d.repo === repo && d.status === 'running')
@ -152,7 +193,6 @@ app.post('/deploy/:repo', async (request, reply) => {
return reply.status(202).send({ ok: true, deploying: repo }) 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) => { 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)
@ -167,11 +207,19 @@ app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
return reply.status(200).send({ ok: true, skipped: 'not main branch' }) return reply.status(200).send({ ok: true, skipped: 'not main branch' })
} }
const target = deployMap[repoName] // Refresh registry on push so a newly registered app deploys immediately
await refreshRegistry()
const target = getDeployTarget(repoName)
if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' }) if (!target) return reply.status(200).send({ ok: true, skipped: 'no deploy target' })
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 })
}) })
// ── Start ─────────────────────────────────────────────────────────────────────
await refreshRegistry()
setInterval(refreshRegistry, 5 * 60 * 1000) // keep registry fresh
await app.listen({ port: 9000, host: '0.0.0.0' }) await app.listen({ port: 9000, host: '0.0.0.0' })