202 lines
7.9 KiB
JavaScript
202 lines
7.9 KiB
JavaScript
/**
|
|
* Deploys route factory — shipdeck-backed source deploys (DC-130).
|
|
*
|
|
* Bridge architecture: the shipdeck CLI (SSH keys, fleet-dns creds, root)
|
|
* lives on the DNS2 HOST. A token-gated shipdeck-bridge daemon
|
|
* (/opt/shipdeck-bridge, systemd shipdeck-bridge.service, 127.0.0.1:8977 +
|
|
* docker bridge 172.17.0.1:8977) wraps the CLI. This route proxies to it —
|
|
* the container never touches SSH or DNS credentials.
|
|
*
|
|
* Endpoints (all under /api/v1/deploys, standard dashboard auth):
|
|
* GET /repos -> deployable repos (dirs with Shipdeckfile)
|
|
* GET /services -> deployed services (journal-derived)
|
|
* GET /journal?service=N -> journal rows
|
|
* GET /status?service=N -> live re-probe
|
|
* POST /deploy {dir} -> run a deploy (long; up to SHIPDECK_DEPLOY_TIMEOUT)
|
|
* POST /rollback {service} -> roll back to the previous release
|
|
*
|
|
* Opt-in: when SHIPDECK_BRIDGE_URL is unset every endpoint returns 501 with a
|
|
* clear message (the DC-048 opt-in pattern: the feature does not exist until
|
|
* the operator configures it).
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { ok, errorResponse } = require('../src/utils/responses');
|
|
|
|
const SHIPDECK_BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || '';
|
|
const SHIPDECK_BRIDGE_TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || '';
|
|
const SHIPDECK_PROBE_TIMEOUT = Number(process.env.SHIPDECK_PROBE_TIMEOUT || 15000);
|
|
const SHIPDECK_DEPLOY_TIMEOUT = Number(process.env.SHIPDECK_DEPLOY_TIMEOUT || 620000);
|
|
|
|
const SERVICE_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
|
|
function readBridgeToken() {
|
|
if (!SHIPDECK_BRIDGE_TOKEN_FILE) return '';
|
|
const fs = require('fs');
|
|
try {
|
|
return fs.readFileSync(SHIPDECK_BRIDGE_TOKEN_FILE, 'utf8').trim();
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
module.exports = function ({ asyncHandler, log, auditLogger, fetchT }) {
|
|
const router = express.Router();
|
|
|
|
function featureEnabled() {
|
|
return SHIPDECK_BRIDGE_URL !== '';
|
|
}
|
|
|
|
function notConfigured(res) {
|
|
return errorResponse(res, 501, 'Deploys feature not configured: set SHIPDECK_BRIDGE_URL (and SHIPDECK_BRIDGE_TOKEN_FILE) to enable');
|
|
}
|
|
|
|
/**
|
|
* Proxy to the bridge. Returns {status, body}.
|
|
* bodyStream: pass a longer timeout for deploy/rollback.
|
|
*/
|
|
async function bridge(method, path, body, timeoutMs) {
|
|
const token = readBridgeToken();
|
|
const headers = { 'X-Shipdeck-Token': token };
|
|
let payload;
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
payload = JSON.stringify(body);
|
|
}
|
|
const res = await fetchT(SHIPDECK_BRIDGE_URL + path, {
|
|
method,
|
|
headers,
|
|
body: payload,
|
|
}, timeoutMs || SHIPDECK_PROBE_TIMEOUT);
|
|
let parsed;
|
|
try {
|
|
parsed = await res.json();
|
|
} catch (e) {
|
|
parsed = { ok: false, error: 'bridge returned non-JSON response' };
|
|
}
|
|
return { status: res.status, body: parsed };
|
|
}
|
|
|
|
// ---- feature gate for every endpoint ----
|
|
router.use((req, res, next) => {
|
|
if (!featureEnabled()) return notConfigured(res);
|
|
next();
|
|
});
|
|
|
|
router.get('/repos', asyncHandler(async (req, res) => {
|
|
const { status, body } = await bridge('GET', '/api/repos');
|
|
if (status !== 200 || !body.ok) {
|
|
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
|
|
}
|
|
return ok(res, { repos: body.repos });
|
|
}));
|
|
|
|
router.get('/services', asyncHandler(async (req, res) => {
|
|
const { status, body } = await bridge('GET', '/api/services');
|
|
if (status !== 200 || !body.ok) {
|
|
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
|
|
}
|
|
return ok(res, { services: body.services });
|
|
}));
|
|
|
|
router.get('/journal', asyncHandler(async (req, res) => {
|
|
const service = String(req.query.service || '');
|
|
if (service && !SERVICE_RE.test(service)) {
|
|
return errorResponse(res, 400, 'invalid service name');
|
|
}
|
|
const qs = service ? `?service=${encodeURIComponent(service)}` : '';
|
|
const { status, body } = await bridge('GET', '/api/journal' + qs);
|
|
if (status !== 200 && status !== 500) {
|
|
return errorResponse(res, status === 401 ? 502 : status, body.error || 'bridge error');
|
|
}
|
|
return ok(res, { rows: body.rows || [], ok: body.ok });
|
|
}));
|
|
|
|
router.get('/status', asyncHandler(async (req, res) => {
|
|
const service = String(req.query.service || '');
|
|
if (!SERVICE_RE.test(service)) {
|
|
return errorResponse(res, 400, 'invalid service name');
|
|
}
|
|
let status, body;
|
|
try {
|
|
({ status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`));
|
|
} catch (e) {
|
|
log.error('deploys', 'status probe: bridge unreachable', { service, error: e.message });
|
|
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
|
|
}
|
|
if (status === 401 || status === 403) {
|
|
// bridge auth/protocol failure = infrastructure problem, NOT a probe result
|
|
log.error('deploys', 'status probe: bridge auth failed', { service, status });
|
|
return errorResponse(res, 502, 'shipdeck bridge rejected the request (auth/config error)');
|
|
}
|
|
if (status >= 500 && body && body.ok === false && body.output) {
|
|
// shipdeck status exits non-zero when checks fail — that's an EXPECTED
|
|
// probe result (failing checks), surface as ok:false with the output.
|
|
return ok(res, { ok: false, output: body.output || '' });
|
|
}
|
|
if (status !== 200) {
|
|
// malformed upstream route or other unexpected bridge failure
|
|
log.error('deploys', 'status probe: unexpected bridge response', {
|
|
service,
|
|
status,
|
|
bridgeError: body && body.error ? String(body.error).slice(0, 200) : 'none',
|
|
});
|
|
return errorResponse(res, 502, 'shipdeck bridge protocol error (unexpected response, status ' + status + ')');
|
|
}
|
|
return ok(res, { ok: body.ok === true, output: body.output || '' });
|
|
}));
|
|
|
|
router.post('/deploy', asyncHandler(async (req, res) => {
|
|
const dir = req.body && req.body.dir;
|
|
if (typeof dir !== 'string' || !dir.trim()) {
|
|
return errorResponse(res, 400, 'dir is required');
|
|
}
|
|
try {
|
|
const { status, body } = await bridge('POST', '/api/deploy', { dir }, SHIPDECK_DEPLOY_TIMEOUT);
|
|
if (auditLogger) {
|
|
auditLogger.log({
|
|
action: 'deploy.shipdeck',
|
|
resource: dir,
|
|
details: { dir, exit: body.exit },
|
|
outcome: body.ok ? 'success' : 'failure',
|
|
}).catch(() => {});
|
|
}
|
|
if (status !== 200 || !body.ok) {
|
|
log.warn('deploys', 'shipdeck deploy failed', { dir, exit: body.exit });
|
|
return errorResponse(res, status === 401 ? 502 : status === 500 ? 502 : status, body.error || 'deploy failed', { output: (body.output || '').slice(-4000) });
|
|
}
|
|
log.info('deploys', 'shipdeck deploy completed', { dir });
|
|
return ok(res, { exit: body.exit, output: body.output });
|
|
} catch (e) {
|
|
log.error('deploys', 'bridge unreachable', { error: e.message });
|
|
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
|
|
}
|
|
}));
|
|
|
|
router.post('/rollback', asyncHandler(async (req, res) => {
|
|
const service = req.body && req.body.service;
|
|
if (typeof service !== 'string' || !SERVICE_RE.test(service)) {
|
|
return errorResponse(res, 400, 'invalid service name');
|
|
}
|
|
try {
|
|
const { status, body } = await bridge('POST', '/api/rollback', { service }, SHIPDECK_DEPLOY_TIMEOUT);
|
|
if (auditLogger) {
|
|
auditLogger.log({
|
|
action: 'deploy.rollback',
|
|
resource: service,
|
|
details: { service, exit: body.exit },
|
|
outcome: body.ok ? 'success' : 'failure',
|
|
}).catch(() => {});
|
|
}
|
|
if (status !== 200 || !body.ok) {
|
|
return errorResponse(res, 502, body.error || 'rollback failed', { output: (body.output || '').slice(-4000) });
|
|
}
|
|
return ok(res, { exit: body.exit, output: body.output });
|
|
} catch (e) {
|
|
return errorResponse(res, 502, 'shipdeck bridge unreachable: ' + e.message);
|
|
}
|
|
}));
|
|
|
|
return router;
|
|
};
|