add pipeline ci/cd
This commit is contained in:
@@ -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
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGro
|
|||||||
|
|
||||||
export const swaggerPlugin = openapi({
|
export const swaggerPlugin = openapi({
|
||||||
path: '/swagger',
|
path: '/swagger',
|
||||||
provider: 'scalar',
|
provider: 'swagger-ui',
|
||||||
mapJsonSchema: {
|
mapJsonSchema: {
|
||||||
zod: z.toJSONSchema,
|
zod: z.toJSONSchema,
|
||||||
},
|
},
|
||||||
@@ -64,4 +64,7 @@ export const swaggerPlugin = openapi({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as OpenApiDocumentation,
|
} as OpenApiDocumentation,
|
||||||
|
exclude: {
|
||||||
|
paths: ['/live', '/ready', '/'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user