[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,211 @@
|
||||
/**
|
||||
* DC-137: shipdeck engine branch for App Selector catalog installs.
|
||||
*
|
||||
* Pins:
|
||||
* - engineEnabledFor: bridge configured + compatible template → true;
|
||||
* bridge unset, static sites, and privileged/capability templates → false
|
||||
* - deployViaEngine: maps template docker fields to the validated bridge
|
||||
* image-install payload (image, port, env placeholder-stripped, mounts
|
||||
* filtered) and surfaces bridge failures with stage detail
|
||||
* - route integration: config.engine='shipdeck' routes through the engine,
|
||||
* skips Docker + panel DNS/Caddy (engine pipeline did them), registers
|
||||
* the service with a shipdeck manifest, and a bridge failure returns
|
||||
* 502 WITHOUT falling back to Docker
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc137-'));
|
||||
const TOKEN_FILE = path.join(TMP_DIR, 'bridge-token');
|
||||
fs.writeFileSync(TOKEN_FILE, 'test-token-456');
|
||||
|
||||
process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:8977';
|
||||
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = TOKEN_FILE;
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const bridge = require('../src/shipdeck-bridge-client');
|
||||
const engine = require('../src/apps-shipdeck-engine');
|
||||
|
||||
const uptimeTemplate = {
|
||||
name: 'Uptime Kuma',
|
||||
category: 'Monitoring',
|
||||
defaultPort: 3002,
|
||||
subdomain: 'uptime',
|
||||
docker: {
|
||||
image: 'louislam/uptime-kuma:latest',
|
||||
ports: ['{{PORT}}:3001'],
|
||||
volumes: ['/opt/uptime/data:/app/data'],
|
||||
environment: { SOME_FLAG: '1' },
|
||||
},
|
||||
healthCheck: '/web/index.html',
|
||||
};
|
||||
|
||||
const privilegedTemplate = {
|
||||
name: 'Wireguard',
|
||||
defaultPort: 51820,
|
||||
docker: {
|
||||
image: 'linuxserver/wireguard:latest',
|
||||
ports: ['{{PORT}}:51820'],
|
||||
capabilities: ['NET_ADMIN'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('DC-137: engine gating', () => {
|
||||
const savedUrl = process.env.SHIPDECK_BRIDGE_URL;
|
||||
|
||||
test('bridge configured + compatible template → enabled', () => {
|
||||
expect(bridge.isEnabled()).toBe(true);
|
||||
expect(engine.engineEnabledFor(uptimeTemplate)).toBe(true);
|
||||
});
|
||||
|
||||
test('bridge unconfigured → disabled even for compatible templates', () => {
|
||||
process.env.SHIPDECK_BRIDGE_URL = '';
|
||||
jest.resetModules();
|
||||
const bridge2 = require('../src/shipdeck-bridge-client');
|
||||
const engine2 = require('../src/apps-shipdeck-engine');
|
||||
expect(bridge2.isEnabled()).toBe(false);
|
||||
expect(engine2.engineEnabledFor(uptimeTemplate)).toBe(false);
|
||||
process.env.SHIPDECK_BRIDGE_URL = savedUrl;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
test('static site → not engine compatible', () => {
|
||||
expect(engine.engineEnabledFor({ ...uptimeTemplate, isStaticSite: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('capabilities/privileged templates → not engine compatible, with reasons', () => {
|
||||
expect(engine.engineEnabledFor(privilegedTemplate)).toBe(false);
|
||||
expect(engine.templateIncompatibilityReasons(privilegedTemplate)).toContain('capabilities');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-137: deployViaEngine payload mapping', () => {
|
||||
test('maps template fields into the validated bridge payload; strips placeholders', async () => {
|
||||
let captured;
|
||||
const capturedPayloads = [];
|
||||
const origCall = bridge.call;
|
||||
bridge.call = async (method, path, body) => {
|
||||
captured = { method, path, body };
|
||||
capturedPayloads.push(body);
|
||||
return { status: 200, body: { ok: true, service: { name: body.name, image: 'louislam/uptime-kuma:latest@sha256:aa', shipdeckfile: '/var/lib/shipdeck/services/' + body.name + '/Shipdeckfile' }, output: 'ok' } };
|
||||
};
|
||||
try {
|
||||
const result = await engine.deployViaEngine({
|
||||
appId: 'uptime-kuma',
|
||||
template: uptimeTemplate,
|
||||
config: { subdomain: 'uptime', port: 3002 }, // host port 3002 must NOT leak into the engine payload
|
||||
processedTemplate: {
|
||||
docker: {
|
||||
image: 'louislam/uptime-kuma:latest',
|
||||
ports: ['3002:3001'],
|
||||
volumes: ['/opt/uptime/data:/app/data', '/opt/plex/{{MEDIA_PATH}}:/data'],
|
||||
environment: { SOME_FLAG: '1', PLEX_CLAIM: '{{CLAIM_TOKEN}}' },
|
||||
},
|
||||
},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
expect(result.engine).toBe(true);
|
||||
expect(captured.method).toBe('POST');
|
||||
expect(captured.path).toBe('/api/image/install');
|
||||
expect(captured.body.image).toBe('louislam/uptime-kuma:latest');
|
||||
expect(captured.body.name).toBe('uptime');
|
||||
expect(captured.body.subdomain).toBe('uptime');
|
||||
// container/listen port (3001) wins over host-selected 3002 — the
|
||||
// engine runs host-networked, so shipdeck must gate the listen port
|
||||
expect(captured.body.port).toBe(3001);
|
||||
// env placeholder stripped, plain values preserved
|
||||
expect(captured.body.env.SOME_FLAG).toBe('1');
|
||||
expect(captured.body.env.PLEX_CLAIM).toBe('');
|
||||
// named volumes + media placeholder filtered, real bind kept with ro flag support
|
||||
expect(captured.body.mounts.length).toBe(1);
|
||||
expect(captured.body.mounts[0]).toEqual({ source: '/opt/uptime/data', target: '/app/data', read_only: false });
|
||||
// read-only bind preserved
|
||||
const ro = await engine.deployViaEngine({
|
||||
appId: 'ro-test',
|
||||
template: uptimeTemplate,
|
||||
config: { subdomain: 'ro-test', port: 3002 },
|
||||
processedTemplate: {
|
||||
docker: {
|
||||
image: 'louislam/uptime-kuma:latest',
|
||||
ports: ['{{PORT}}:3001'],
|
||||
volumes: ['/etc/localtime:/etc/localtime:ro', 'named-volume:/var/lib/data'],
|
||||
environment: {},
|
||||
},
|
||||
},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
expect(ro.engine).toBe(true);
|
||||
// second payload: :ro translated to read_only=true, named volume dropped
|
||||
const roPayload = capturedPayloads[1];
|
||||
expect(roPayload.mounts).toEqual([
|
||||
{ source: '/etc/localtime', target: '/etc/localtime', read_only: true },
|
||||
]);
|
||||
} finally {
|
||||
bridge.call = origCall;
|
||||
}
|
||||
});
|
||||
|
||||
test('bridge failure surfaces stage detail and does not throw a generic error', async () => {
|
||||
const origCall = bridge.call;
|
||||
bridge.call = async () => ({ status: 500, body: { ok: false, error: 'image deploy failed', output: 'verify: http-tailnet FAIL' } });
|
||||
try {
|
||||
await expect(engine.deployViaEngine({
|
||||
appId: 'uptime-kuma',
|
||||
template: uptimeTemplate,
|
||||
config: { subdomain: 'uptime', port: 3002 },
|
||||
processedTemplate: { docker: { image: 'louislam/uptime-kuma:latest', ports: [], volumes: [], environment: {} } },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
})).rejects.toThrow(/verify: http-tailnet FAIL/);
|
||||
} finally {
|
||||
bridge.call = origCall;
|
||||
}
|
||||
});
|
||||
|
||||
test('protocol-qualified mapping (host:container/udp) resolves the listen port', async () => {
|
||||
let captured;
|
||||
const origCall = bridge.call;
|
||||
bridge.call = async (method, path, body) => {
|
||||
captured = body;
|
||||
return { status: 200, body: { ok: true, service: { name: body.name, image: 'x@sha256:aa', shipdeckfile: '/x' }, output: 'ok' } };
|
||||
};
|
||||
try {
|
||||
await engine.deployViaEngine({
|
||||
appId: 'dns-app',
|
||||
template: { name: 'DnsApp', defaultPort: 5380, docker: { image: 'dns/app:latest', ports: ['{{PORT}}:5353/udp'], volumes: [], environment: {} } },
|
||||
config: { subdomain: 'dnsapp', port: 5380 },
|
||||
processedTemplate: { docker: { image: 'dns/app:latest', ports: ['{{PORT}}:5353/udp'], volumes: [], environment: {} } },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
expect(captured.port).toBe(5353); // /udp suffix stripped
|
||||
} finally {
|
||||
bridge.call = origCall;
|
||||
}
|
||||
});
|
||||
|
||||
test('no mapping: defaultPort wins over user-selected config.port (host port is a Docker concept)', async () => {
|
||||
let captured;
|
||||
const origCall = bridge.call;
|
||||
bridge.call = async (method, path, body) => {
|
||||
captured = body;
|
||||
return { status: 200, body: { ok: true, service: { name: body.name, image: 'x@sha256:aa', shipdeckfile: '/x' }, output: 'ok' } };
|
||||
};
|
||||
try {
|
||||
await engine.deployViaEngine({
|
||||
appId: 'nomap',
|
||||
template: { name: 'NoMap', defaultPort: 8096, docker: { image: 'app:latest', ports: [], volumes: [], environment: {} } },
|
||||
config: { subdomain: 'nomap', port: 9999 }, // user-chosen Docker host port
|
||||
processedTemplate: { docker: { image: 'app:latest', ports: [], volumes: [], environment: {} } },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {} },
|
||||
});
|
||||
expect(captured.port).toBe(8096);
|
||||
expect(captured.port).not.toBe(9999);
|
||||
} finally {
|
||||
bridge.call = origCall;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* DC-137: route integration tests for the shipdeck engine branch.
|
||||
*
|
||||
* Route-level pins (the unit tests in catalog-engine-dc137.test.js cover
|
||||
* gating + payload mapping):
|
||||
* - POST /apps/deploy with config.engine='shipdeck' + compatible template:
|
||||
* calls bridge /api/image/install, NEVER calls Docker create, skips
|
||||
* panel DNS + Caddy writes, registers the service with an engine=shipdeck
|
||||
* manifest, responds engine:'shipdeck'.
|
||||
* - Bridge failure: 502 with stage detail, Docker create never invoked
|
||||
* (no silent fallback), no service registration.
|
||||
* - DELETE /apps/:appId for an engine-installed service: runs shipdeck rm
|
||||
* through the bridge instead of Docker container removal.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc137-route-'));
|
||||
const TOKEN_FILE = path.join(TMP_DIR, 'bridge-token');
|
||||
fs.writeFileSync(TOKEN_FILE, 'tok-789');
|
||||
|
||||
process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:8977';
|
||||
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = TOKEN_FILE;
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const uptimeTemplate = {
|
||||
id: 'uptime-kuma',
|
||||
name: 'Uptime Kuma',
|
||||
category: 'Monitoring',
|
||||
defaultPort: 3002,
|
||||
subdomain: 'uptime',
|
||||
logo: '/assets/uptime-kuma.png',
|
||||
docker: {
|
||||
image: 'louislam/uptime-kuma:latest',
|
||||
ports: ['{{PORT}}:3001'],
|
||||
volumes: ['/opt/uptime/data:/app/data'],
|
||||
environment: { SOME_FLAG: '1' },
|
||||
},
|
||||
healthCheck: '/web/index.html',
|
||||
subpathSupport: 'strip',
|
||||
};
|
||||
|
||||
function buildDeps(overrides = {}) {
|
||||
return Object.assign({
|
||||
docker: {
|
||||
client: {
|
||||
getContainer: () => { throw new Error('DOCKER MUST NOT BE CALLED'); },
|
||||
listImages: () => { throw new Error('DOCKER MUST NOT BE CALLED'); },
|
||||
pruneImages: () => { throw new Error('DOCKER MUST NOT BE CALLED'); },
|
||||
},
|
||||
},
|
||||
caddy: {
|
||||
generateConfig: () => { throw new Error('CADDY GENERATE MUST NOT BE CALLED FOR ENGINE INSTALLS'); },
|
||||
modify: () => { throw new Error('CADDY MODIFY MUST NOT BE CALLED FOR ENGINE INSTALLS'); },
|
||||
},
|
||||
credentialManager: { retrieve: async () => null },
|
||||
servicesStateManager: {
|
||||
read: async () => [],
|
||||
update: async (fn) => fn([]),
|
||||
},
|
||||
portLockManager: { acquire: async () => () => {}, release: () => {} },
|
||||
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||
errorResponse: (res, code, msg, extra = {}) => res.status(code).json({ success: false, error: msg, ...extra }),
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
helpers: undefined, // wired below (real helpers need too much ctx)
|
||||
APP_TEMPLATES: { 'uptime-kuma': uptimeTemplate },
|
||||
siteConfig: { routingMode: 'subdomain', domain: 'sami', dnsServerIp: '127.0.0.1' },
|
||||
buildDomain: (sub) => `${sub}.sami`,
|
||||
buildServiceUrl: (sub) => `https://${sub}.sami`,
|
||||
addServiceToConfig: async (svc) => svc,
|
||||
dns: {
|
||||
universalCreateRecord: () => { throw new Error('PANEL DNS MUST NOT BE CALLED FOR ENGINE INSTALLS'); },
|
||||
getToken: () => null,
|
||||
},
|
||||
notification: { send: () => {} },
|
||||
safeErrorMessage: (m) => m,
|
||||
SERVICES_FILE: path.join(TMP_DIR, 'services.json'),
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
function buildApp(deps) {
|
||||
const factory = require('../routes/apps/deploy');
|
||||
const router = factory(deps);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/v1/apps', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('DC-137 routes: engine install via POST /apps/deploy', () => {
|
||||
test('engine install: bridge called, Docker/DNS/Caddy untouched, manifest recorded', async () => {
|
||||
const calls = [];
|
||||
const deps = buildDeps({
|
||||
servicesStateManager: {
|
||||
read: async () => [],
|
||||
update: async () => [],
|
||||
},
|
||||
addServiceToConfig: async (svc) => { calls.push(['register', svc]); return svc; },
|
||||
});
|
||||
// real helpers from the apps module (processTemplateVariables etc.)
|
||||
const initHelpers = require('../routes/apps/helpers');
|
||||
deps.helpers = initHelpers({ ...deps, ctx: { siteConfig: deps.siteConfig, docker: deps.docker } });
|
||||
// engine module uses DI-free bridge client; intercept at the HTTP seam
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push(['bridge', url, JSON.parse(opts.body)]);
|
||||
return { ok: true, status: 200, json: async () => ({ ok: true, service: { name: 'uptime', image: 'louislam/uptime-kuma:latest@sha256:aa', shipdeckfile: '/var/lib/shipdeck/services/uptime/Shipdeckfile' }, output: 'deployed' }) };
|
||||
};
|
||||
try {
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app)
|
||||
.post('/api/v1/apps/deploy')
|
||||
.send({ appId: 'uptime-kuma', config: { subdomain: 'uptime', port: 3002, engine: 'shipdeck', createDns: false } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.engine).toBe('shipdeck');
|
||||
expect(calls.some(c => c[0] === 'bridge' && String(c[1]).includes('/api/image/install'))).toBe(true);
|
||||
// container-side port (3001) won over host-selected 3002
|
||||
const bridgeCall = calls.find(c => c[0] === 'bridge');
|
||||
expect(bridgeCall[2].port).toBe(3001);
|
||||
// service registered with engine manifest
|
||||
const reg = calls.find(c => c[0] === 'register');
|
||||
expect(reg).toBeDefined();
|
||||
expect(reg[1].deploymentManifest.engine).toBe('shipdeck');
|
||||
expect(reg[1].deploymentManifest.shipdeck.shipdeckfile).toContain('/uptime/');
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('bridge failure: 502 with stage detail, no Docker fallback, no registration', async () => {
|
||||
const calls = [];
|
||||
let registered = false;
|
||||
const deps = buildDeps({
|
||||
addServiceToConfig: async (svc) => { registered = true; return svc; },
|
||||
});
|
||||
const initHelpers = require('../routes/apps/helpers');
|
||||
deps.helpers = initHelpers({ ...deps, ctx: { siteConfig: deps.siteConfig, docker: deps.docker } });
|
||||
const origFetch = global.fetch;
|
||||
global.fetch = async (url) => {
|
||||
calls.push(['bridge', url]);
|
||||
return { ok: false, status: 500, json: async () => ({ ok: false, error: 'image deploy failed', output: 'verify FAIL http-tailnet' }) };
|
||||
};
|
||||
try {
|
||||
const app = buildApp(deps);
|
||||
const res = await request(app)
|
||||
.post('/api/v1/apps/deploy')
|
||||
.send({ appId: 'uptime-kuma', config: { subdomain: 'uptime', port: 3002, engine: 'shipdeck' } });
|
||||
expect(res.status).toBe(502);
|
||||
expect(JSON.stringify(res.body)).toContain('verify FAIL');
|
||||
expect(calls.filter(c => c[0] === 'bridge').length).toBe(1); // single attempt
|
||||
expect(registered).toBe(false);
|
||||
} finally {
|
||||
global.fetch = origFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const { ValidationError } = require('../../src/utilities/errors');
|
||||
const { logError } = require('../../src/utils/logging');
|
||||
const { ok } = require('../../src/utils/responses');
|
||||
const { validateBody, schemas: valSchemas } = require('../../src/utilities/validate');
|
||||
const shipdeckEngine = require('../../src/apps-shipdeck-engine');
|
||||
/**
|
||||
* Apps deployment routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
@@ -292,7 +293,29 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
// Process template variables for manifest (only needed for Docker containers)
|
||||
const processedTemplate = template.isStaticSite ? null : helpers.processTemplateVariables(template, config);
|
||||
|
||||
if (template.isStaticSite) {
|
||||
// DC-137: shipdeck engine branch — when the bridge is configured and
|
||||
// the template is engine-compatible, install via shipdeck (digest-
|
||||
// pinned image, systemd release, Caddy gate, DNS, verify) and skip
|
||||
// the Docker path entirely.
|
||||
let engineResult = null;
|
||||
if (!template.isStaticSite && !config.useExisting && config.engine === 'shipdeck' && shipdeckEngine.engineEnabledFor(template)) {
|
||||
try {
|
||||
engineResult = await shipdeckEngine.deployViaEngine({
|
||||
appId, template, config,
|
||||
processedTemplate: helpers.processTemplateVariables(template, config),
|
||||
log,
|
||||
});
|
||||
containerId = null;
|
||||
} catch (engineError) {
|
||||
// Engine failure is surfaced, never silently retried on Docker —
|
||||
// a fallback deploy would double-bind the subdomain and the
|
||||
// operator must see exactly which stage failed.
|
||||
await logError('app-deploy-engine', engineError, { appId, subdomain: config.subdomain });
|
||||
return errorResponse(res, 502, safeErrorMessage
|
||||
? safeErrorMessage(engineError.message)
|
||||
: `shipdeck engine install failed: ${engineError.message}`);
|
||||
}
|
||||
} else if (template.isStaticSite) {
|
||||
log.info('deploy', 'Deploying static site', { appId });
|
||||
if (appId === 'dashca') {
|
||||
await deployDashCAStaticSite(template, config);
|
||||
@@ -315,9 +338,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
|
||||
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
|
||||
|
||||
// DNS record creation (skip in subdirectory mode — only one domain needed)
|
||||
// DNS record creation (skip in subdirectory mode — only one domain needed;
|
||||
// also skipped for engine installs — shipdeck's pipeline already created it)
|
||||
let dnsWarning = null;
|
||||
if (config.createDns && !isSubdirectoryMode) {
|
||||
if (engineResult) {
|
||||
log.info('deploy', 'DNS handled by shipdeck engine', { appId, record: engineResult.service && engineResult.service.name });
|
||||
} else if (config.createDns && !isSubdirectoryMode) {
|
||||
try {
|
||||
await ctx.dns.universalCreateRecord(config.subdomain, config.ip);
|
||||
log.info('deploy', 'DNS record created', { domain: ctx.buildDomain(config.subdomain), ip: config.ip });
|
||||
@@ -335,7 +361,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
subpathSupport: template.subpathSupport || 'strip',
|
||||
};
|
||||
let caddyConfig;
|
||||
if (template.isStaticSite) {
|
||||
if (engineResult) {
|
||||
// Engine installs: shipdeck wrote the Caddy block already (tailnet-
|
||||
// only, gated). Nothing to generate or write here.
|
||||
caddyConfig = null;
|
||||
log.info('deploy', 'Caddy handled by shipdeck engine', { appId });
|
||||
} else if (template.isStaticSite) {
|
||||
const sitePath = platformPaths.sitePath(config.subdomain);
|
||||
if (appId === 'dashca') {
|
||||
caddyOptions.httpAccess = true;
|
||||
@@ -346,8 +377,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
caddyConfig = caddy.generateConfig(config.subdomain, config.ip, config.port || template.defaultPort, caddyOptions);
|
||||
}
|
||||
|
||||
// Write Caddy config (subdirectory: inject into main block; subdomain: append as new block)
|
||||
if (isSubdirectoryMode && !template.isStaticSite) {
|
||||
// Write Caddy config (subdirectory: inject into main block; subdomain:
|
||||
// append as new block; engine installs: already written by shipdeck)
|
||||
if (engineResult) {
|
||||
// no-op — pipeline wrote it
|
||||
} else if (isSubdirectoryMode && !template.isStaticSite) {
|
||||
await helpers.ensureMainDomainBlock();
|
||||
await helpers.addSubpathConfig(config.subdomain, caddyConfig);
|
||||
} else {
|
||||
@@ -358,9 +392,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
// Build service URL based on routing mode
|
||||
const serviceUrl = ctx.buildServiceUrl(config.subdomain);
|
||||
|
||||
// Build deployment manifest — the full recipe to recreate this container
|
||||
// Build deployment manifest — the full recipe to recreate this service.
|
||||
// Engine installs record the shipdeck recipe (digest-pinned Shipdeckfile
|
||||
// path) instead of a Docker container recipe.
|
||||
const deploymentManifest = {
|
||||
templateId: appId,
|
||||
engine: engineResult ? 'shipdeck' : 'docker',
|
||||
config: {
|
||||
subdomain: config.subdomain,
|
||||
port: config.port || template.defaultPort,
|
||||
@@ -372,7 +409,13 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
customVolumes: config.customVolumes || undefined,
|
||||
useExisting: false
|
||||
},
|
||||
container: template.isStaticSite ? null : {
|
||||
shipdeck: engineResult ? {
|
||||
service: engineResult.service && engineResult.service.name,
|
||||
image: engineResult.service && engineResult.service.image,
|
||||
shipdeckfile: engineResult.service && engineResult.service.shipdeckfile,
|
||||
port: engineResult.enginePort // actual engine listen port, not the Docker host port
|
||||
} : undefined,
|
||||
container: (!engineResult && !template.isStaticSite) ? {
|
||||
image: processedTemplate.docker.image,
|
||||
ports: processedTemplate.docker.ports,
|
||||
volumes: processedTemplate.docker.volumes || [],
|
||||
@@ -387,7 +430,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
return env;
|
||||
})(),
|
||||
capabilities: processedTemplate.docker.capabilities || undefined
|
||||
},
|
||||
} : null,
|
||||
caddy: {
|
||||
tailscaleOnly: config.tailscaleOnly || false,
|
||||
allowedIPs: config.allowedIPs || [],
|
||||
@@ -410,6 +453,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
|
||||
const response = {
|
||||
success: true, containerId, usedExisting,
|
||||
engine: engineResult ? 'shipdeck' : 'docker',
|
||||
url: serviceUrl,
|
||||
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
|
||||
setupInstructions: template.setupInstructions || []
|
||||
|
||||
@@ -45,7 +45,29 @@ module.exports = function({
|
||||
try {
|
||||
log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer });
|
||||
|
||||
if (containerId && shouldDeleteContainer) {
|
||||
// DC-137: engine-installed apps run as shipdeck services (no Docker
|
||||
// container). Detect via the services registry BEFORE touching Docker.
|
||||
let engineService = null;
|
||||
try {
|
||||
const svcList = await servicesStateManager.read();
|
||||
const svc = (Array.isArray(svcList) ? svcList : []).find(s => s.id === subdomain);
|
||||
if (svc && svc.deploymentManifest && svc.deploymentManifest.engine === 'shipdeck') {
|
||||
engineService = svc.deploymentManifest.shipdeck && svc.deploymentManifest.shipdeck.service;
|
||||
}
|
||||
} catch (_) { /* registry read failure falls through to legacy path */ }
|
||||
|
||||
if (engineService && shouldDeleteContainer) {
|
||||
// Engine path: `shipdeck rm` removes unit + releases (Caddy/DNS are
|
||||
// handled below by the shared removal code, same as Docker apps).
|
||||
try {
|
||||
const { call } = require('../../src/shipdeck-bridge-client');
|
||||
const { status, body } = await call('POST', '/api/rm', { name: engineService }, 120000);
|
||||
results.container = (status === 200 && body.ok) ? 'removed (shipdeck)' : `shipdeck rm failed: ${body.error || status}`;
|
||||
log.info('deploy', 'shipdeck service removal', { engineService, result: results.container });
|
||||
} catch (error) {
|
||||
results.container = `shipdeck bridge unreachable: ${error.message}`;
|
||||
}
|
||||
} else if (containerId && shouldDeleteContainer) {
|
||||
try {
|
||||
const container = docker.client.getContainer(containerId);
|
||||
try { await container.stop(); log.info('docker', 'Container stopped', { containerId }); }
|
||||
|
||||
@@ -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