[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
+53 -9
View File
@@ -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 || []