/** * 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; } }); });