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