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