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:
parent
b120fbf44e
commit
c26f7e18b2
3 changed files with 83 additions and 46 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -12,23 +11,68 @@ 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' })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue