DC-130: shipdeck deploys panel — bridge-proxied source deploys (routes + tests, opt-in via SHIPDECK_BRIDGE_URL)

This commit is contained in:
DashCaddy Polish Loop
2026-09-14 03:33:31 -07:00
parent e33bc91438
commit 1fc61c3fdb
4 changed files with 399 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
20260722-065235-cookie-only-session-653478a
70e252c
@@ -0,0 +1,210 @@
/**
* Routes tests for /api/v1/deploys (DC-130 — shipdeck bridge proxy).
*
* Pattern: build the router with stub deps, hit it via a tiny express app,
* assert response shapes and the exact proxy interactions with fetchT.
* The feature gate (SHIPDECK_BRIDGE_URL) is exercised in both states.
*/
const express = require('express');
const FIXTURE_REPOS = { ok: true, repos: [{ dir: '/root/helloworld', name: 'helloworld' }] };
const FIXTURE_SERVICES = {
ok: true,
services: [{ name: 'helloworld', host: null, last_action: 'deploy', last_time: '2026-09-14T05:00:00Z', last_epoch: 1789360000 }],
};
const FIXTURE_ROWS = {
ok: true,
rows: [{ time: '2026-09-14T05:00:00Z', service: 'helloworld', action: 'deploy', epoch: 1789360000, duration: '30.0s' }],
};
function buildApp(fetchT, env = {}) {
process.env.SHIPDECK_BRIDGE_URL = env.url !== undefined ? env.url : 'http://172.17.0.1:8977';
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = env.tokenFile !== undefined ? env.tokenFile : '';
jest.resetModules();
const mod = require('../../routes/deploys');
const router = mod({
asyncHandler: (fn) => async (req, res, next) => {
try { await fn(req, res, next); } catch (e) { next(e); }
},
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
auditLogger: { log: jest.fn(async () => {}) },
fetchT,
});
const app = express();
app.use(express.json());
app.use('/api/v1/deploys', router);
// express error middleware → json shape like production
app.use((err, req, res, next) => {
res.status(500).json({ success: false, error: err.message });
});
return app;
}
function jsonFetcher(responses) {
// responses: map of "METHOD path" -> {status, body}
const calls = [];
return {
calls,
fetchT: jest.fn(async (url, opts) => {
const key = `${(opts && opts.method) || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
calls.push({ key, opts });
const r = responses[key] || { status: 404, body: { ok: false, error: 'no fixture' } };
return {
status: r.status,
json: async () => r.body,
};
}),
};
}
describe('routes/deploys — feature gate', () => {
test('501 with clear message when SHIPDECK_BRIDGE_URL unset', async () => {
const app = buildApp(jsonFetcher({}).fetchT, { url: '' });
const res = await app.inject ? null : null; // supertest absent; use fetch via server
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
const body = await r.json();
server.close();
expect(r.status).toBe(501);
expect(body.success).toBe(false);
expect(body.error).toMatch(/SHIPDECK_BRIDGE_URL/);
});
});
describe('routes/deploys — proxied endpoints', () => {
test('GET /repos proxies and unwraps bridge payload', async () => {
const f = jsonFetcher({ 'GET /api/repos': { status: 200, body: FIXTURE_REPOS } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.repos).toHaveLength(1);
expect(body.repos[0].name).toBe('helloworld');
expect(f.calls[0].opts.headers['X-Shipdeck-Token']).toBeDefined();
});
test('GET /services proxies inventory', async () => {
const f = jsonFetcher({ 'GET /api/services': { status: 200, body: FIXTURE_SERVICES } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/services`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.services[0].name).toBe('helloworld');
});
test('GET /journal rejects invalid service names (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=..%2Fetc`);
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('GET /journal passes valid service filter', async () => {
const f = jsonFetcher({ 'GET /api/journal?service=helloworld': { status: 200, body: FIXTURE_ROWS } });
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.rows[0].action).toBe('deploy');
});
test('GET /status returns ok:false with output when probe fails (no throw)', async () => {
const f = jsonFetcher({
'GET /api/status?service=helloworld': { status: 500, body: { ok: false, output: '[FAIL] http-tailnet' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.ok).toBe(false);
expect(body.output).toContain('[FAIL]');
});
test('POST /deploy proxies dir and audits', async () => {
const f = jsonFetcher({
'POST /api/deploy': { status: 200, body: { ok: true, exit: 0, output: 'DEPLOYED helloworld in 30.0s' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: '/root/helloworld' }),
});
const body = await r.json();
server.close();
expect(r.status).toBe(200);
expect(body.success).toBe(true);
expect(body.output).toContain('DEPLOYED');
expect(JSON.parse(f.calls[0].opts.body).dir).toBe('/root/helloworld');
});
test('POST /deploy rejects missing dir (400, no proxy call)', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('POST /rollback rejects invalid service name', async () => {
const f = jsonFetcher({});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'bad name; rm' }),
});
server.close();
expect(r.status).toBe(400);
expect(f.calls).toHaveLength(0);
});
test('bridge 500 surfaces as 502 with output excerpt', async () => {
const f = jsonFetcher({
'POST /api/deploy': { status: 500, body: { ok: false, exit: 8, output: 'service did not listen' } },
});
const app = buildApp(f.fetchT);
const server = app.listen(0);
const port = server.address().port;
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: '/root/helloworld' }),
});
const body = await r.json();
server.close();
expect(r.status).toBe(502);
expect(body.success).toBe(false);
});
});
+178
View File
@@ -0,0 +1,178 @@
/**
* 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');
}
const { status, body } = await bridge('GET', `/api/status?service=${encodeURIComponent(service)}`);
// shipdeck status exits non-zero when checks fail — surface that as ok:false
// with the probe output so the UI can render the failing checks.
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;
};
+10
View File
@@ -99,6 +99,7 @@ const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
const securityRoutes = require('../routes/security');
const deploysRoutes = require('../routes/deploys');
const diskSettingsRoutes = require('../routes/disk-settings');
const aiIntentRoutes = require('../routes/ai-intent');
const logInsightsRoutes = require('../routes/log-insights');
@@ -775,6 +776,15 @@ async function createApp() {
log: ctx.log,
}));
// DC-130: shipdeck deploys — source deploys via the host-side bridge.
// Feature-flagged: 501 unless SHIPDECK_BRIDGE_URL is set (opt-in pattern).
apiRouter.use('/deploys', deploysRoutes({
asyncHandler: ctx.asyncHandler,
log: ctx.log,
auditLogger: ctx.auditLogger,
fetchT: ctx.fetchT,
}));
// Log Insights — plain English activity summary + safe log disposal
apiRouter.use('/disk-settings', diskSettingsRoutes);
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));