#!/usr/bin/env node 'use strict'; const http = require('http'); const { execFile } = require('child_process'); const url = require('url'); const fs = require('fs'); // Config via environment variables (see README guidance in CI output) const PORT = parseInt(process.env.PORT || '8080', 10); const TOKEN = process.env.DEPLOY_WEBHOOK_TOKEN || process.env.WEBHOOK_TOKEN || ''; const STACK_FILE = process.env.STACK_FILE || '/opt/deploy/docker-stack.yml'; const DOCKER_BIN = process.env.DOCKER_BIN || 'docker'; const RATE_LIMIT_SECONDS = parseInt(process.env.RATE_LIMIT_SECONDS || '15', 10); const LOCK_FILE = process.env.LOCK_FILE || '/tmp/savy-deploy.lock'; const ALLOW_STACKS = (process.env.ALLOW_STACKS || 'savy').split(',').map(s => s.trim()).filter(Boolean); const ALLOW_SERVICES = (process.env.ALLOW_SERVICES || 'api').split(',').map(s => s.trim()).filter(Boolean); // Optional registry credentials if your manager node needs to login before pull/deploy const DOCKER_REGISTRY = process.env.DOCKER_REGISTRY || ''; const DOCKER_REGISTRY_USER = process.env.DOCKER_REGISTRY_USER || ''; const DOCKER_REGISTRY_PASSWORD = process.env.DOCKER_REGISTRY_PASSWORD || ''; let lastDeployTs = 0; function json(res, code, obj) { const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(body); } function readBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', chunk => { data += chunk; if (data.length > 1e6) { // 1MB safety limit try { req.socket.destroy(); } catch {} reject(new Error('payload too large')); } }); req.on('end', () => resolve(data)); req.on('error', reject); }); } function validateImage(image) { // Basic allowlist regex for image:tag return typeof image === 'string' && /^[a-z0-9._\/:-]+:[a-zA-Z0-9._-]+$/.test(image); } function execCmd(cmd, args, envExtra) { return new Promise((resolve, reject) => { execFile(cmd, args, { env: { ...process.env, ...envExtra } }, (err, stdout, stderr) => { if (err) { err.stdout = stdout; err.stderr = stderr; return reject(err); } resolve({ stdout, stderr }); }); }); } function acquireLock() { try { const fd = fs.openSync(LOCK_FILE, 'wx'); fs.writeFileSync(fd, String(process.pid)); return fd; } catch { return null; } } function releaseLock(fd) { if (fd) { try { fs.closeSync(fd); } catch {} try { fs.unlinkSync(LOCK_FILE); } catch {} } } function unauthorized(res) { res.writeHead(401); res.end('unauthorized'); } const server = http.createServer(async (req, res) => { const parsed = url.parse(req.url || '', true); if (req.method === 'GET' && parsed.pathname === '/health') { return json(res, 200, { status: 'ok', ts: Date.now() }); } if (req.method !== 'POST' || parsed.pathname !== '/deploy') { return json(res, 404, { error: 'not_found' }); } // Bearer token check (do not log the token) const auth = req.headers['authorization'] || ''; const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''; if (!TOKEN || token !== TOKEN) { return unauthorized(res); } // Simple rate-limit to avoid rapid repeat-triggering const now = Date.now(); if (now - lastDeployTs < RATE_LIMIT_SECONDS * 1000) { return json(res, 429, { error: 'rate_limited', retry_after_seconds: Math.ceil((RATE_LIMIT_SECONDS * 1000 - (now - lastDeployTs)) / 1000) }); } let body = ''; try { body = await readBody(req); } catch { return json(res, 400, { error: 'read_error' }); } let payload; try { payload = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'invalid_json' }); } const app = String(payload.app || ''); const image = String(payload.image || ''); const env = String(payload.env || ''); const stack = String(payload.stack || ''); const service = String(payload.service || ''); if (!validateImage(image)) { return json(res, 400, { error: 'invalid_image' }); } if (!ALLOW_STACKS.includes(stack)) { return json(res, 403, { error: 'stack_not_allowed' }); } if (service && !ALLOW_SERVICES.includes(service)) { return json(res, 403, { error: 'service_not_allowed' }); } const lockfd = acquireLock(); if (!lockfd) { return json(res, 423, { error: 'another_deploy_in_progress' }); } lastDeployTs = now; console.log(`[deploy] image=${image} env=${env} stack=${stack} service=${service} at ${new Date().toISOString()}`); try { if (DOCKER_REGISTRY && DOCKER_REGISTRY_USER && DOCKER_REGISTRY_PASSWORD) { console.log('[deploy] docker login registry'); await execCmd(DOCKER_BIN, ['login', '-u', DOCKER_REGISTRY_USER, '-p', DOCKER_REGISTRY_PASSWORD, DOCKER_REGISTRY], {}); } console.log('[deploy] docker pull'); await execCmd(DOCKER_BIN, ['pull', image], {}); // Pass image and environment to stack via env const envExtra = { IMAGE: image, APP_NAME: app, DEPLOY_ENV: env }; console.log('[deploy] docker stack deploy'); await execCmd(DOCKER_BIN, ['stack', 'deploy', '-c', STACK_FILE, stack, '--with-registry-auth'], envExtra); console.log('[deploy] success'); return json(res, 200, { ok: true, deployed_image: image, stack, service, env }); } catch (e) { console.error('[deploy] error', e && e.message, e && e.stderr); return json(res, 500, { error: 'deploy_failed', message: (e && e.message) || 'unknown' }); } finally { releaseLock(lockfd); } }); server.listen(PORT, () => { console.log(`Webhook receiver listening on :${PORT}`); });