diff --git a/dashcaddy-api/__tests__/catalog-engine-dc137.test.js b/dashcaddy-api/__tests__/catalog-engine-dc137.test.js new file mode 100644 index 0000000..07e2784 --- /dev/null +++ b/dashcaddy-api/__tests__/catalog-engine-dc137.test.js @@ -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; + } + }); +}); diff --git a/dashcaddy-api/__tests__/catalog-engine-routes-dc137.test.js b/dashcaddy-api/__tests__/catalog-engine-routes-dc137.test.js new file mode 100644 index 0000000..0e94512 --- /dev/null +++ b/dashcaddy-api/__tests__/catalog-engine-routes-dc137.test.js @@ -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; + } + }); +}); diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index 3e7909b..0938a46 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -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 || [] diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 9b9a687..2b5393e 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -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 }); } diff --git a/dashcaddy-api/src/apps-shipdeck-engine.js b/dashcaddy-api/src/apps-shipdeck-engine.js new file mode 100644 index 0000000..2ed972d --- /dev/null +++ b/dashcaddy-api/src/apps-shipdeck-engine.js @@ -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, +}; diff --git a/dashcaddy-api/src/shipdeck-bridge-client.js b/dashcaddy-api/src/shipdeck-bridge-client.js new file mode 100644 index 0000000..994410b --- /dev/null +++ b/dashcaddy-api/src/shipdeck-bridge-client.js @@ -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 }; diff --git a/status/js/app-selector.js b/status/js/app-selector.js index 3855161..1a0aef2 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -762,7 +762,8 @@ tailscaleOnly: deployConfig.tailscaleOnly || false, // Tailscale-only access restriction mediaPath: deployConfig.mediaPath || null, // Media folder path for media apps plexClaimToken: deployConfig.plexClaimToken || null, // Plex claim token for auto-claim - customVolumes: deployConfig.customVolumes || null // Custom volume mount overrides + customVolumes: deployConfig.customVolumes || null, // Custom volume mount overrides + engine: deployConfig.engine || null // DC-137: 'shipdeck' opts this install into the shipdeck engine } };