Files
dashcaddy/dashcaddy-api/__tests__/catalog-engine-dc137.test.js
T
DashCaddy Polish Loop 4ca805795b [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.
2026-09-16 04:04:53 -07:00

212 lines
8.6 KiB
JavaScript

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