diff --git a/deploy/custom-webhook/receiver.js b/deploy/custom-webhook/receiver.js deleted file mode 100644 index a5193f8..0000000 --- a/deploy/custom-webhook/receiver.js +++ /dev/null @@ -1,183 +0,0 @@ -#!/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}`); -}); \ No newline at end of file