Initial commit: management
This commit is contained in:
commit
ab329d497b
8 changed files with 243 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
WEBHOOK_SECRET=CHANGE_ME_match_forgejo_webhook_secret
|
||||||
|
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
|
||||||
|
KUMA_PUSH_URL=http://localhost:3001/api/push/KUMA_TOKEN
|
||||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Docker volumes (if any are mounted locally)
|
||||||
|
postgres-data/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
*.tar.gz
|
||||||
|
*.tmp
|
||||||
46
backup/backup.sh
Normal file
46
backup/backup.sh
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR=/backups
|
||||||
|
PG_HOST=10.10.10.100
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR/postgres/daily" "$BACKUP_DIR/postgres/weekly" "$BACKUP_DIR/volumes"
|
||||||
|
|
||||||
|
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||||
|
|
||||||
|
# ── PostgreSQL dumps ──────────────────────────────────────────────────────────
|
||||||
|
log "Starting PostgreSQL backups..."
|
||||||
|
for DB in $BACKUP_DATABASES; do
|
||||||
|
OUTFILE="$BACKUP_DIR/postgres/daily/${DB}_${TIMESTAMP}.sql.gz"
|
||||||
|
PGPASSWORD="$PG_SUPERPASS" pg_dump -h "$PG_HOST" -U postgres "$DB" | gzip > "$OUTFILE"
|
||||||
|
log " $DB → $(du -sh "$OUTFILE" | cut -f1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Weekly full dump on Sundays
|
||||||
|
if [ "$(date +%u)" = "7" ]; then
|
||||||
|
log "Weekly full dump..."
|
||||||
|
PGPASSWORD="$PG_SUPERPASS" pg_dumpall -h "$PG_HOST" -U postgres \
|
||||||
|
| gzip > "$BACKUP_DIR/postgres/weekly/full_${TIMESTAMP}.sql.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Retention cleanup ─────────────────────────────────────────────────────────
|
||||||
|
find "$BACKUP_DIR/postgres/daily" -name "*.gz" -mtime +7 -delete
|
||||||
|
find "$BACKUP_DIR/postgres/weekly" -name "*.gz" -mtime +28 -delete
|
||||||
|
log "Retention cleanup done"
|
||||||
|
|
||||||
|
# ── Rsync to remote ───────────────────────────────────────────────────────────
|
||||||
|
if [ -n "$BACKUP_REMOTE" ]; then
|
||||||
|
log "Syncing to $BACKUP_REMOTE..."
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -i /root/.ssh/id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
|
"$BACKUP_DIR/" "$BACKUP_REMOTE"
|
||||||
|
log "Sync complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Uptime Kuma heartbeat ─────────────────────────────────────────────────────
|
||||||
|
if [ -n "$KUMA_PUSH_URL" ]; then
|
||||||
|
wget -qO- "$KUMA_PUSH_URL?status=up&msg=Backup+OK&ping=" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Backup complete"
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
services:
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
volumes:
|
||||||
|
- kuma_data:/app/data
|
||||||
|
ports:
|
||||||
|
- "3002:3001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
updater:
|
||||||
|
build: ./updater
|
||||||
|
environment:
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
|
- SSH_KEY_PATH=/root/.ssh/id_ed25519
|
||||||
|
volumes:
|
||||||
|
- /root/.ssh:/root/.ssh:ro
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backup:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
entrypoint: ["/bin/sh", "-c", "crond -f -l 8"]
|
||||||
|
environment:
|
||||||
|
- BACKUP_DATABASES=${BACKUP_DATABASES}
|
||||||
|
- PG_SUPERPASS=${PG_SUPERPASS}
|
||||||
|
- BACKUP_REMOTE=${BACKUP_REMOTE}
|
||||||
|
- KUMA_PUSH_URL=${KUMA_PUSH_URL}
|
||||||
|
volumes:
|
||||||
|
- backup_data:/backups
|
||||||
|
- ./backup/backup.sh:/etc/periodic/daily/backup:ro
|
||||||
|
- /root/.ssh:/root/.ssh:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kuma_data:
|
||||||
|
backup_data:
|
||||||
15
updater/Dockerfile
Normal file
15
updater/Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssh-client
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY deploy-map.js ./
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
14
updater/deploy-map.js
Normal file
14
updater/deploy-map.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// 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' },
|
||||||
|
}
|
||||||
9
updater/package.json
Normal file
9
updater/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "hnf-updater",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": { "start": "node src/index.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"fastify": "^4.28.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
updater/src/index.js
Normal file
72
updater/src/index.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { deployMap } from '../deploy-map.js'
|
||||||
|
|
||||||
|
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 deployLog = []
|
||||||
|
|
||||||
|
function verifySignature(body, signature) {
|
||||||
|
if (!WEBHOOK_SECRET) return true // disabled in dev
|
||||||
|
const expected = `sha256=${crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex')}`
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deploy(target, repoName) {
|
||||||
|
const { host, path } = target
|
||||||
|
const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${host} "cd ${path} && git pull && docker compose up -d --build"`
|
||||||
|
|
||||||
|
const entry = { repo: repoName, 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`)
|
||||||
|
} catch (err) {
|
||||||
|
entry.status = 'failed'
|
||||||
|
entry.error = err.message.slice(-500)
|
||||||
|
app.log.error(`Deploy ${repoName} → failed: ${err.message}`)
|
||||||
|
}
|
||||||
|
entry.finished = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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(() => {})
|
||||||
|
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' })
|
||||||
Loading…
Add table
Add a link
Reference in a new issue