From 1fc61c3fdb60b9b1ed933dfe4df57b2bedecfe81 Mon Sep 17 00:00:00 2001 From: DashCaddy Polish Loop Date: Mon, 14 Sep 2026 03:33:31 -0700 Subject: [PATCH] =?UTF-8?q?DC-130:=20shipdeck=20deploys=20panel=20?= =?UTF-8?q?=E2=80=94=20bridge-proxied=20source=20deploys=20(routes=20+=20t?= =?UTF-8?q?ests,=20opt-in=20via=20SHIPDECK=5FBRIDGE=5FURL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashcaddy-api/VERSION | 2 +- .../__tests__/routes/deploys.routes.test.js | 210 ++++++++++++++++++ dashcaddy-api/routes/deploys.js | 178 +++++++++++++++ dashcaddy-api/src/app.js | 10 + 4 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 dashcaddy-api/__tests__/routes/deploys.routes.test.js create mode 100644 dashcaddy-api/routes/deploys.js diff --git a/dashcaddy-api/VERSION b/dashcaddy-api/VERSION index b1869ed..b7c4395 100644 --- a/dashcaddy-api/VERSION +++ b/dashcaddy-api/VERSION @@ -1 +1 @@ -20260722-065235-cookie-only-session-653478a +70e252c diff --git a/dashcaddy-api/__tests__/routes/deploys.routes.test.js b/dashcaddy-api/__tests__/routes/deploys.routes.test.js new file mode 100644 index 0000000..27afff5 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/deploys.routes.test.js @@ -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); + }); +}); diff --git a/dashcaddy-api/routes/deploys.js b/dashcaddy-api/routes/deploys.js new file mode 100644 index 0000000..b35fbb1 --- /dev/null +++ b/dashcaddy-api/routes/deploys.js @@ -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; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index d534dfa..963d462 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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 }));