[grade=A] Shipdeck fleet module and manual deploy flow

Adds validated fleet install/lifecycle routes, bridge-backed Git and OCI workflows, persistent service cards, and the Local-tab Shipdeck deployment UI while preserving catalog and External flows.

Judge: urn:ump:if6udffdyelsf4qvskkr65ikhuprsjiajzij6h653fhf2zgyynzq
This commit is contained in:
DashCaddy Polish Loop
2026-09-15 11:13:29 -07:00
parent f9cbb13a3d
commit 0dd8493f98
14 changed files with 1624 additions and 230 deletions
@@ -0,0 +1,70 @@
const express = require('express');
function fetcher(fixtures) {
return jest.fn(async (url, opts = {}) => {
const key = `${opts.method || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
const hit = fixtures[key] || { status: 404, body: { ok: false, error: 'missing fixture' } };
return { status: hit.status, json: async () => hit.body };
});
}
function appFor(fixtures = {}, initial = []) {
process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:8977';
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = '';
jest.resetModules();
const make = require('../../routes/shipdeck-fleet');
let services = initial.slice();
const router = make({
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: fetcher(fixtures),
servicesStateManager: { read: async () => services, update: async (fn) => { services = await fn(services); } },
});
const app = express(); app.use(express.json()); app.use('/api/v1/fleet', router);
app.use((err, req, res, next) => res.status(500).json({ success: false, error: err.message }));
return { app, services: () => services };
}
async function request(app, path, options) {
const server = app.listen(0); const port = server.address().port;
try { const response = await fetch(`http://127.0.0.1:${port}${path}`, options); return { response, body: await response.json() }; }
finally { server.close(); }
}
describe('Shipdeck fleet routes', () => {
test('from-git rejects privileged inputs before bridge', async () => {
const { app } = appFor();
const { response } = await request(app, '/api/v1/fleet/from-git', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo_url: 'https://github.com/a/b;id', name: '../bad', subdomain: 'bad', port: 80 }) });
expect(response.status).toBe(400);
});
test('from-git persists the card server-side while never returning or storing the token', async () => {
const fixtures = { 'POST /api/install': { status: 200, body: { ok: true, service: { logo: '', host: 'localhost', shipdeckfile: '/var/lib/shipdeck/services/demo/Shipdeckfile', journal_row_id: 'demo:1' } } } };
const { app, services } = appFor(fixtures);
const secret = 'ghp_private_secret';
const { response, body } = await request(app, '/api/v1/fleet/from-git', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo_url: 'https://github.com/acme/demo', name: 'demo', subdomain: 'demo', port: 8080, token: secret }) });
expect(response.status).toBe(200); expect(body.success).toBe(true);
expect(JSON.stringify(body)).not.toContain(secret); expect(JSON.stringify(services())).not.toContain(secret);
expect(services()[0].managedBy).toBe('shipdeck');
expect(services()[0].shipdeckfile).toBe('/var/lib/shipdeck/services/demo/Shipdeckfile');
});
test('from-image validates mounts and registry refs', async () => {
const { app } = appFor();
const { response } = await request(app, '/api/v1/fleet/from-image', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ image: 'alpine;id', name: 'demo', subdomain: 'demo', port: 8080, mounts: [{ source: '/tmp/../etc', target: '/data' }] }) });
expect(response.status).toBe(400);
});
test('lifecycle validates service and proxies argv-shaped action', async () => {
const { app } = appFor({ 'POST /api/restart': { status: 200, body: { ok: true, output: 'RESTART demo' } } });
const { response, body } = await request(app, '/api/v1/fleet/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'demo' }) });
expect(response.status).toBe(200); expect(body.action).toBe('restart');
});
test('shipdeckfile requires registered canonical path', async () => {
const { app } = appFor({}, [{ id: 'demo', managedBy: 'shipdeck', shipdeckfile: '/tmp/evil' }]);
const { response } = await request(app, '/api/v1/fleet/shipdeckfile?id=demo');
expect(response.status).toBe(404);
});
});
+12 -1
View File
@@ -43,6 +43,7 @@ const path = require('path');
const crypto = require('crypto');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');
const shipdeckFleet = require('./shipdeck-fleet');
const {
validateFleetHost,
resolveAndCheckAddress,
@@ -58,7 +59,7 @@ const MAX_PROBE_CONCURRENCY = 5;
// Per-host probe timeout for /fleet/status.
const PROBE_TIMEOUT_MS = 3000;
module.exports = function({ log, asyncHandler }) {
module.exports = function({ log, asyncHandler, auditLogger, fetchT, servicesStateManager }) {
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
const router = express.Router();
@@ -352,5 +353,15 @@ module.exports = function({ log, asyncHandler }) {
});
}));
// Shipdeck v0.2 lifecycle and install endpoints share the existing /fleet
// namespace without changing the DC-108 host-management routes above.
router.use('/fleet', shipdeckFleet({
asyncHandler: wrap,
log,
auditLogger,
fetchT,
servicesStateManager,
}));
return router;
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Shipdeck fleet module. All privileged values are fail-closed here before
* crossing the token-gated host bridge. Tokens are forwarded in-memory only:
* never logged, audited, persisted, or returned.
*/
const express = require('express');
const { ok, errorResponse } = require('../src/utils/responses');
const BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || '';
const BRIDGE_TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || '';
const PROBE_TIMEOUT = Number(process.env.SHIPDECK_PROBE_TIMEOUT || 15000);
const DEPLOY_TIMEOUT = Number(process.env.SHIPDECK_DEPLOY_TIMEOUT || 920000);
const reServiceName = /^[a-z0-9][a-z0-9-]{0,62}$/;
const reBinaryPath = /^\/?[A-Za-z0-9][A-Za-z0-9._\-/]{0,255}$/;
const reSHA256 = /^[a-f0-9]{64}$/;
const reRegistryRef = /^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?\/)?[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9][A-Za-z0-9._-]{0,127}|@sha256:[a-f0-9]{64})$/;
const reGitURL = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?\/?$/;
const reEnvName = /^[A-Z_][A-Z0-9_]*$/;
const reToken = /^[A-Za-z0-9_.=~-]{0,512}$/;
const reUser = /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?$/;
const reMountPath = new RegExp('^/[A-Za-z0-9._/-]+$');
function hasControl(value) {
return Array.from(value).some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127);
}
function cleanEnv(value) {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('env must be an object');
const out = {};
for (const [key, val] of Object.entries(value)) {
if (!reEnvName.test(key) || typeof val !== 'string' || val.length > 300 || val.includes('"') || val.includes('\\') || hasControl(val)) {
throw new Error('env must map uppercase names to strings <=300 chars without quotes, backslashes, or control characters');
}
out[key] = val;
}
return out;
}
function cleanMounts(value) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 32) throw new Error('mounts must be an array of at most 32 entries');
return value.map((mount) => {
if (!mount || typeof mount !== 'object' || Array.isArray(mount)) throw new Error('invalid mount');
const source = String(mount.source || '');
const target = String(mount.target || '');
if (!reMountPath.test(source) || !reMountPath.test(target) || source.includes('..') || target.includes('..')) throw new Error('mount paths must be safe absolute paths');
if (mount.read_only !== undefined && typeof mount.read_only !== 'boolean') throw new Error('mount read_only must be boolean');
return { source, target, read_only: mount.read_only === true };
});
}
function cleanCommand(value) {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 64 || value.some((v) => typeof v !== 'string' || !v || v.length > 1024 || hasControl(v))) throw new Error('cmd must be an array of safe argument strings');
if (!reBinaryPath.test(value[0])) throw new Error('cmd executable is invalid');
return value.slice();
}
function readToken() {
if (!BRIDGE_TOKEN_FILE) return '';
try { return require('fs').readFileSync(BRIDGE_TOKEN_FILE, 'utf8').trim(); } catch (_) { return ''; }
}
module.exports = function fleetRoutes({ asyncHandler, log, auditLogger, fetchT, servicesStateManager }) {
const router = express.Router();
async function bridge(method, path, body, timeout = PROBE_TIMEOUT) {
const headers = { 'X-Shipdeck-Token': readToken() };
const options = { method, headers };
if (body !== undefined) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(body); }
const response = await fetchT(BRIDGE_URL + path, options, timeout);
let parsed;
try { parsed = await response.json(); } catch (_) { parsed = { ok: false, error: 'bridge returned non-JSON response' }; }
return { status: response.status, body: parsed };
}
function bridgeError(res, status, body, fallback) {
const outward = status === 400 || status === 409 ? status : 502;
return errorResponse(res, outward, body.error || fallback, body.output ? { output: String(body.output).slice(-4000) } : undefined);
}
router.use((req, res, next) => {
if (!BRIDGE_URL) return errorResponse(res, 501, 'Fleet feature not configured: set SHIPDECK_BRIDGE_URL');
next();
});
router.post('/from-git', asyncHandler(async (req, res) => {
const { repo_url: repoUrl, name, subdomain, port, token, sha256 } = req.body || {};
if (typeof repoUrl !== 'string' || !reGitURL.test(repoUrl)) return errorResponse(res, 400, 'repo_url must be https://host/owner/repo');
if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name');
if (typeof subdomain !== 'string' || !reServiceName.test(subdomain)) return errorResponse(res, 400, 'invalid subdomain');
if (!Number.isInteger(port) || port < 1 || port > 65535) return errorResponse(res, 400, 'port must be 1-65535');
if (sha256 !== undefined && (typeof sha256 !== 'string' || !reSHA256.test(sha256))) return errorResponse(res, 400, 'sha256 must be 64 lowercase hex characters');
if (token !== undefined && (typeof token !== 'string' || !reToken.test(token))) return errorResponse(res, 400, 'invalid token');
let env; try { env = cleanEnv(req.body.env); } catch (e) { return errorResponse(res, 400, e.message); }
try {
const payload = { repo_url: repoUrl, service: name, subdomain, port, env, sha256 };
if (token !== undefined) payload.token = token;
const { status, body } = await bridge('POST', '/api/install', payload, DEPLOY_TIMEOUT);
if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Git deployment failed');
const deployed = body.service || {};
const card = {
id: name, name, url: `https://${subdomain}.sami`, ip: deployed.host || 'localhost',
port, logo: deployed.logo || `/assets/${name}.png`, tailscaleOnly: true,
isCustom: true, managedBy: 'shipdeck', repoUrl,
shipdeckfile: deployed.shipdeckfile, journalRowId: deployed.journal_row_id,
};
await servicesStateManager.update((services) => {
const i = services.findIndex((s) => s.id === name);
if (i >= 0) services[i] = { ...services[i], ...card }; else services.push(card);
return services;
});
if (auditLogger) auditLogger.log({ action: 'fleet.from-git', resource: name, details: { repo_url: repoUrl, port }, outcome: 'success' }).catch(() => {});
log.info('fleet', 'Shipdeck Git deployment completed', { service: name, port });
return ok(res, { service: card, journal_row_id: deployed.journal_row_id, phase: 'live' });
} catch (e) {
log.error('fleet', 'bridge unreachable during Git deployment', { service: name, error: e.message });
return errorResponse(res, 502, 'shipdeck bridge unreachable');
}
}));
router.post('/from-image', asyncHandler(async (req, res) => {
const { image, name, subdomain, port, user, restart, sha256 } = req.body || {};
if (typeof image !== 'string' || !reRegistryRef.test(image)) return errorResponse(res, 400, 'invalid registry image reference');
if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name');
if (typeof subdomain !== 'string' || !reServiceName.test(subdomain)) return errorResponse(res, 400, 'invalid subdomain');
if (!Number.isInteger(port) || port < 1 || port > 65535) return errorResponse(res, 400, 'port must be 1-65535');
if (sha256 !== undefined && (typeof sha256 !== 'string' || !reSHA256.test(sha256))) return errorResponse(res, 400, 'sha256 must be 64 lowercase hex characters');
if (user !== undefined && (typeof user !== 'string' || !reUser.test(user))) return errorResponse(res, 400, 'invalid user');
if (restart !== undefined && !['no', 'always', 'unless-stopped', 'on-failure'].includes(restart)) return errorResponse(res, 400, 'invalid restart policy');
let env, mounts, cmd;
try { env = cleanEnv(req.body.env); mounts = cleanMounts(req.body.mounts); cmd = cleanCommand(req.body.cmd); } catch (e) { return errorResponse(res, 400, e.message); }
try {
const { status, body } = await bridge('POST', '/api/image/install', { image, name, subdomain, port, env, mounts, user, restart, cmd, sha256 }, DEPLOY_TIMEOUT);
if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Image deployment failed');
const deployed = body.service || {};
const card = { id: name, name, url: `https://${subdomain}.sami`, ip: 'localhost', port, logo: `/assets/${name}.png`, tailscaleOnly: true, isCustom: true, managedBy: 'shipdeck', image: deployed.image || image, shipdeckfile: deployed.shipdeckfile, journalRowId: deployed.journal_row_id };
await servicesStateManager.update((services) => { const i = services.findIndex((s) => s.id === name); if (i >= 0) services[i] = { ...services[i], ...card }; else services.push(card); return services; });
if (auditLogger) auditLogger.log({ action: 'fleet.from-image', resource: name, details: { image, port }, outcome: 'success' }).catch(() => {});
return ok(res, { service: card, journal_row_id: deployed.journal_row_id, phase: 'live' });
} catch (e) { log.error('fleet', 'bridge unreachable during image deployment', { service: name, error: e.message }); return errorResponse(res, 502, 'shipdeck bridge unreachable'); }
}));
router.get('/list', asyncHandler(async (req, res) => {
const { status, body } = await bridge('GET', '/api/managed');
if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'fleet listing failed');
return ok(res, { services: body.services || [] });
}));
for (const action of ['start', 'stop', 'restart', 'rm']) {
router.post('/' + action, asyncHandler(async (req, res) => {
const name = req.body && req.body.name;
if (typeof name !== 'string' || !reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name');
const { status, body } = await bridge('POST', '/api/' + action, { name }, DEPLOY_TIMEOUT);
if (status !== 200 || !body.ok) return bridgeError(res, status, body, action + ' failed');
if (action === 'rm') await servicesStateManager.update((services) => services.filter((s) => s.id !== name));
return ok(res, { name, action, output: String(body.output || '').slice(-4000) });
}));
}
router.get('/logs', asyncHandler(async (req, res) => {
const name = String(req.query.name || '');
if (!reServiceName.test(name)) return errorResponse(res, 400, 'invalid service name');
const { status, body } = await bridge('GET', '/api/logs?name=' + encodeURIComponent(name));
if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'logs failed');
return ok(res, { name, logs: String(body.output || '').slice(-64000) });
}));
router.get('/shipdeckfile', asyncHandler(async (req, res) => {
const id = String(req.query.id || '');
if (!reServiceName.test(id)) return errorResponse(res, 400, 'invalid service id');
const services = await servicesStateManager.read();
const service = services.find((s) => s.id === id && s.managedBy === 'shipdeck');
if (!service || typeof service.shipdeckfile !== 'string' || !/^\/var\/lib\/shipdeck\/services\/[a-z0-9-]+\/Shipdeckfile$/.test(service.shipdeckfile)) return errorResponse(res, 404, 'Shipdeckfile not registered for this service');
const { status, body } = await bridge('GET', '/api/shipdeckfile?name=' + encodeURIComponent(id));
if (status !== 200 || !body.ok) return bridgeError(res, status, body, 'Shipdeckfile read failed');
return ok(res, { id, shipdeckfile: String(body.shipdeckfile || '') });
}));
return router;
};
module.exports._validation = { reServiceName, reBinaryPath, reSHA256, reRegistryRef, cleanEnv, cleanMounts, cleanCommand };
+3
View File
@@ -676,6 +676,9 @@ async function createApp() {
apiRouter.use(fleetRoutes({
log: ctx.log,
asyncHandler: ctx.asyncHandler,
auditLogger: ctx.auditLogger,
fetchT: ctx.fetchT,
servicesStateManager: ctx.servicesStateManager,
}));
apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager,