[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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user