remove deploy folder
This commit is contained in:
@@ -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}`);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user