[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
@@ -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;
}
});
});