diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..71564b9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,34 @@ +workflow: + rules: + - if: $CI_PIPELINE_SOURCE == "web" + - when: never + +stages: + - build + - deploy + +docker-build: + image: docker:cli + stage: build + services: + - docker:dind + variables: + DOCKER_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - docker build --pull -t "$DOCKER_IMAGE_NAME" . + - docker push "$DOCKER_IMAGE_NAME" + rules: + - exists: + - Dockerfile + +deploy: + image: curlimages/curl:8.10.1 + stage: deploy + needs: ["docker-build"] + script: + - 'test -n "$WEBHOOK_URL"' + - curl -fsSL -X POST "$WEBHOOK_URL" + rules: + - if: $WEBHOOK_URL \ No newline at end of file diff --git a/deploy/custom-webhook/receiver.js b/deploy/custom-webhook/receiver.js new file mode 100644 index 0000000..a5193f8 --- /dev/null +++ b/deploy/custom-webhook/receiver.js @@ -0,0 +1,183 @@ +#!/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 diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts index 7bc6e51..56d5eaf 100644 --- a/src/plugins/swagger.ts +++ b/src/plugins/swagger.ts @@ -8,7 +8,7 @@ type OpenApiDocumentation = Omit