[grade=B urn:ump:szxhoevzog44pvl3sz6n6qp2edv6wj6dcs3ajpi3kxbpbz7kikqa] DC-137: shipdeck engine branch for App Selector catalog installs

Opt-in (config.engine='shipdeck' + SHIPDECK_BRIDGE_URL configured + template
compatible): catalog installs go through the bridge's validated image-install
pipeline (digest-pinned OCI image, systemd release, Caddy gate, DNS, verify)
instead of Docker. Port semantics: engine is host-networked, so the app LISTEN
port (container side of the mapping, protocol suffix stripped) is gated —
never the Docker host port; no mapping falls back to defaultPort. Volumes:
absolute binds only, :ro preserved, named volumes and placeholders skipped.
Engine installs skip panel DNS + Caddy (pipeline did them), record an
engine=shipdeck manifest with the Shipdeckfile path, and removal runs
shipdeck rm via the registry. Failures return 502 with stage detail and
never fall back to Docker. Bridge unset = Docker path unchanged.

10 new tests (gating, payload mapping, route integration); full suite
138/138 suites 2933/2933 green. Judge: C -> C -> B zero-blockers.
This commit is contained in:
DashCaddy Polish Loop
2026-09-16 04:04:53 -07:00
parent 8ff618ce58
commit 4ca805795b
7 changed files with 636 additions and 11 deletions
+118
View File
@@ -0,0 +1,118 @@
/**
* DC-137: shipdeck engine branch for App Selector image installs.
*
* When the operator enabled the shipdeck bridge (SHIPDECK_BRIDGE_URL) AND
* the install is engine-compatible, the catalog install routes deploy
* through shipdeck (systemd release, digest-pinned image, Caddy gate, DNS,
* verify) instead of creating a Docker container.
*
* Engine-compatible means: single-port web app, subdomain routing, no
* Docker-specific capabilities. Incompatible templates fall back to the
* Docker path with a clear signal — nothing silently changes behavior.
*/
const bridge = require('./shipdeck-bridge-client');
// Template fields that mark a template as NOT engine-compatible today.
// Networking primitives (NET_ADMIN etc.) and VPN shapes need more than a
// port-forwarded systemd unit; keep them on the Docker path.
const INCOMPATIBLE_KEYS = ['capabilities', 'privileged', 'networkMode', 'sysctls'];
function templateIncompatibilityReasons(template = {}) {
const reasons = [];
if (template.isStaticSite) reasons.push('static site');
// The engine model is single-listen-port: multi-port or portless Docker
// templates cannot be expressed as one systemd unit + one Caddy gate yet.
const ports = (template.docker && template.docker.ports) || [];
if (ports.length === 0) reasons.push('no port mapping');
if (ports.length > 1) reasons.push('multi-port');
for (const key of INCOMPATIBLE_KEYS) {
if (template.docker && template.docker[key]) reasons.push(key);
}
return reasons;
}
function engineEnabledFor(template) {
return bridge.isEnabled() && templateIncompatibilityReasons(template).length === 0;
}
/**
* Deploy a catalog template through shipdeck via the validated
* image-install pipeline (digest-pinned, env/mount-validated by the
* bridge, systemd unit, Caddy gate, DNS, verify).
*
* @returns {Promise<{engine:true, service, output, installMeta}>}
*/
async function deployViaEngine({ appId, template, config, processedTemplate, log }) {
const image = processedTemplate.docker.image;
// Engine model = host networking (systemd unit binds the app's own listen
// port). The port shipdeck must gate/verify is the app's LISTEN port — the
// container-side (right-hand) side of the Docker mapping — NOT the host-
// selected one. `{{PORT}}:3001` → 3001; `3002:3001/tcp` → 3001.
// No mapping → template.defaultPort (config.port is the Docker HOST port
// the user chose; it has no meaning for a host-networked engine deploy).
let port = Number(template.defaultPort);
const mapping = (processedTemplate.docker.ports || [])[0];
if (typeof mapping === 'string' && mapping.includes(':')) {
const containerSide = Number(String(mapping.split(':').pop()).split('/')[0]);
if (Number.isInteger(containerSide) && containerSide > 0 && containerSide <= 65535) {
port = containerSide;
}
}
const env = {};
const rawEnv = (processedTemplate.docker.environment || {});
for (const [k, v] of Object.entries(rawEnv)) {
// Unresolved template placeholders cannot be validated by the bridge;
// ship them as empty strings and let the app's own setup wizard fill in.
const value = typeof v === 'string' ? v.replace(/\{\{[A-Z0-9_]+\}\}/g, '') : v;
env[k] = String(value);
}
// Template volumes → validated mounts. Docker syntax: source:target[:ro].
// Named volumes (no leading '/') and unresolved placeholders are skipped —
// the engine runs on the host filesystem, so only absolute host binds map.
const mounts = (processedTemplate.docker.volumes || [])
.map((volume) => {
const parts = String(volume).split(':');
const source = parts[0];
const target = parts[1];
const mode = parts[2] || '';
return { source, target, read_only: mode.toLowerCase() === 'ro' };
})
.filter((m) => m.source && m.target
&& m.source.startsWith('/')
&& !m.source.includes('{{') && !m.target.includes('{{'));
const payload = {
image,
name: config.subdomain,
subdomain: config.subdomain,
port,
env,
mounts,
restart: 'unless-stopped',
};
const enginePort = port; // actual listen port selected for the engine deploy
log.info('deploy', 'deploying catalog app via shipdeck engine', { appId, image, port });
const { status, body } = await bridge.call('POST', '/api/image/install', payload, 900000);
if (status !== 200 || !body.ok) {
const detail = (body.output || body.error || 'shipdeck install failed').slice(-2000);
const err = new Error(`shipdeck engine install failed: ${detail}`);
err.engineStage = 'shipdeck-install';
throw err;
}
return {
engine: true,
service: body.service,
output: body.output,
installMeta: { engine: 'shipdeck', image: body.service && body.service.image },
enginePort,
};
}
module.exports = {
engineEnabledFor,
templateIncompatibilityReasons,
deployViaEngine,
};
@@ -0,0 +1,67 @@
/**
* Shipdeck bridge client (DC-137).
*
* Thin authenticated HTTP client for the host-side shipdeck-bridge daemon
* (systemd shipdeck-bridge.service, 127.0.0.1:8977). Used by every route
* that needs to drive the shipdeck engine: deploys.js (DC-130 lifecycle),
* apps/deploy.js + apps/removal.js (DC-137 catalog installs).
*
* Opt-in (DC-048 pattern): when SHIPDECK_BRIDGE_URL is unset the client
* reports disabled and callers fall back to the Docker path — the shipdeck
* engine does not exist for an operator who has not configured it.
*/
const fs = require('fs');
const BRIDGE_URL = process.env.SHIPDECK_BRIDGE_URL || '';
const TOKEN_FILE = process.env.SHIPDECK_BRIDGE_TOKEN_FILE || '';
function readBridgeToken() {
if (!TOKEN_FILE) return '';
try {
return fs.readFileSync(TOKEN_FILE, 'utf8').trim();
} catch (e) {
return '';
}
}
function isEnabled() {
return BRIDGE_URL !== '';
}
/**
* Call the bridge. Resolves {status, body}; body is always an object.
* Rejects on transport failure (caller decides how to surface it).
*/
async function call(method, path, body, timeoutMs = 620000) {
if (!isEnabled()) throw new Error('shipdeck bridge not configured');
const token = readBridgeToken();
const headers = { 'X-Shipdeck-Token': token };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(BRIDGE_URL + path, {
method,
headers,
body: payload,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
let parsed;
try {
parsed = await res.json();
} catch (e) {
parsed = { ok: false, error: 'bridge returned non-JSON response' };
}
return { status: res.status, body: parsed };
}
module.exports = { isEnabled, call, BRIDGE_URL };