/** * 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 return errorResponse(res, 502, body && body.error ? body.error : 'unexpected bridge response'); } 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; };