From c26f7e18b27ddcffc9fa111315a1dd0177039e1b Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 20:49:39 +0000 Subject: [PATCH] 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 --- .env.example | 2 + updater/deploy-map.js | 19 ++------ updater/src/index.js | 108 ++++++++++++++++++++++++++++++------------ 3 files changed, 83 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 68fa6d8..5379819 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ 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 BACKUP_DATABASES=auth_db noticeboard_db kitchen_db cashup_db hk_db BACKUP_REMOTE=user@yourserver.com:/backups/hnf-proxmox diff --git a/updater/deploy-map.js b/updater/deploy-map.js index 550f0e1..de4d376 100644 --- a/updater/deploy-map.js +++ b/updater/deploy-map.js @@ -1,16 +1,3 @@ -// Maps Forgejo repo names → target LXC SSH details. -// Keys are the plain repo names Forgejo sends in the webhook (repository.name). -// The updater only pulls + rebuilds existing LXCs — provisioning is done on the -// 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 -} +// Deploy targets are now fetched dynamically from the auth registry. +// See src/index.js — INFRA_DEPLOY for platform services, appRegistry for user apps. +// This file is no longer imported. diff --git a/updater/src/index.js b/updater/src/index.js index 9febe22..a53750c 100644 --- a/updater/src/index.js +++ b/updater/src/index.js @@ -2,33 +2,77 @@ 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 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 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 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 }, - 'settings': { host: '10.10.10.116', port: 3080 }, -} - function verifySignature(body, signature) { if (!WEBHOOK_SECRET) return true const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}` @@ -61,7 +105,7 @@ async function getForgejoCommit(repo) { } async function refreshStatus(repo) { - const target = deployMap[repo] + const target = getDeployTarget(repo) if (!target) return null const [current, latest] = await Promise.all([ @@ -94,7 +138,7 @@ 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 + delete statusCache[repoName] } catch (err) { entry.status = 'failed' entry.error = err.message.slice(-500) @@ -108,20 +152,18 @@ async function deploy(target, repoName) { app.get('/health', async () => ({ status: 'healthy' })) app.get('/health-status', async () => { - const results = await Promise.all( + const healthMap = getHealthMap() + return 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), - }) + 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) @@ -129,7 +171,7 @@ app.get('/deploys', async () => deployLog) app.get('/status', async (request) => { const single = request.query?.repo const force = request.query?.force === 'true' - const repos = single ? [single] : Object.keys(deployMap) + const repos = single ? [single] : allDeployRepos() const now = Date.now() const results = await Promise.all(repos.map(repo => { const cached = statusCache[repo] @@ -139,10 +181,9 @@ app.get('/status', async (request) => { 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] + const target = getDeployTarget(repo) if (!target) return reply.status(404).send({ error: 'Unknown repo' }) 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 }) }) -// 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) @@ -167,11 +207,19 @@ app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => { 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' }) deploy(target, repoName).catch(() => {}) 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' })