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.
170 lines
7.7 KiB
JavaScript
170 lines
7.7 KiB
JavaScript
const express = require('express');
|
|
const { exists } = require('../../src/utilities/fs-helpers');
|
|
const { logError } = require('../../src/utils/logging');
|
|
const { ok } = require('../../src/utils/responses');
|
|
|
|
module.exports = function({
|
|
docker, caddy, servicesStateManager, asyncHandler, log, helpers,
|
|
errorResponse, dns, siteConfig, buildDomain, SERVICES_FILE, safeErrorMessage
|
|
}) {
|
|
const router = express.Router();
|
|
|
|
// Ctx shim for backward compatibility with existing route code
|
|
const ctx = {
|
|
dns,
|
|
siteConfig,
|
|
buildDomain,
|
|
SERVICES_FILE,
|
|
safeErrorMessage
|
|
};
|
|
|
|
// Remove deployed app
|
|
/**
|
|
* Apps removal routes factory
|
|
* @param {Object} deps - Explicit dependencies
|
|
* @param {Object} deps.docker - Docker client wrapper
|
|
* @param {Object} deps.caddy - Caddy client
|
|
* @param {Object} deps.servicesStateManager - Services state manager
|
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
|
* @param {Object} deps.log - Logger instance
|
|
* @param {Object} deps.helpers - Apps helpers module
|
|
* @param {Function} deps.errorResponse - Error response helper
|
|
* @param {Object} deps.dns - DNS context
|
|
* @param {Object} deps.siteConfig - Site configuration
|
|
* @param {Function} deps.buildDomain - Build domain helper
|
|
* @param {string} deps.SERVICES_FILE - Services file path
|
|
* @param {Function} deps.safeErrorMessage - Safe error message formatter
|
|
* @returns {express.Router}
|
|
*/
|
|
router.delete('/:appId', asyncHandler(async (req, res) => {
|
|
const { appId } = req.params;
|
|
const { containerId, subdomain, ip, deleteContainer } = req.query;
|
|
const shouldDeleteContainer = deleteContainer === 'true';
|
|
const results = { container: null, dns: null, caddy: null, service: null };
|
|
|
|
try {
|
|
log.info('deploy', 'Removing app', { appId, containerId, subdomain, deleteContainer: shouldDeleteContainer });
|
|
|
|
// DC-137: engine-installed apps run as shipdeck services (no Docker
|
|
// container). Detect via the services registry BEFORE touching Docker.
|
|
let engineService = null;
|
|
try {
|
|
const svcList = await servicesStateManager.read();
|
|
const svc = (Array.isArray(svcList) ? svcList : []).find(s => s.id === subdomain);
|
|
if (svc && svc.deploymentManifest && svc.deploymentManifest.engine === 'shipdeck') {
|
|
engineService = svc.deploymentManifest.shipdeck && svc.deploymentManifest.shipdeck.service;
|
|
}
|
|
} catch (_) { /* registry read failure falls through to legacy path */ }
|
|
|
|
if (engineService && shouldDeleteContainer) {
|
|
// Engine path: `shipdeck rm` removes unit + releases (Caddy/DNS are
|
|
// handled below by the shared removal code, same as Docker apps).
|
|
try {
|
|
const { call } = require('../../src/shipdeck-bridge-client');
|
|
const { status, body } = await call('POST', '/api/rm', { name: engineService }, 120000);
|
|
results.container = (status === 200 && body.ok) ? 'removed (shipdeck)' : `shipdeck rm failed: ${body.error || status}`;
|
|
log.info('deploy', 'shipdeck service removal', { engineService, result: results.container });
|
|
} catch (error) {
|
|
results.container = `shipdeck bridge unreachable: ${error.message}`;
|
|
}
|
|
} else if (containerId && shouldDeleteContainer) {
|
|
try {
|
|
const container = docker.client.getContainer(containerId);
|
|
try { await container.stop(); log.info('docker', 'Container stopped', { containerId }); }
|
|
catch (stopError) { log.debug('docker', 'Container stop note', { containerId, note: stopError.message }); }
|
|
await container.remove({ force: true });
|
|
results.container = 'removed';
|
|
log.info('docker', 'Container removed', { containerId });
|
|
// Prune dangling images after removal
|
|
try {
|
|
const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
|
|
if (pruneResult.SpaceReclaimed > 0) {
|
|
log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
|
|
}
|
|
} catch (pruneErr) {
|
|
log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message });
|
|
}
|
|
} catch (error) {
|
|
results.container = error.message.includes('no such container') ? 'already removed' : error.message;
|
|
}
|
|
} else if (containerId && !shouldDeleteContainer) {
|
|
results.container = 'kept (user choice)';
|
|
}
|
|
|
|
if (shouldDeleteContainer && subdomain && ctx.dns.getToken()) {
|
|
try {
|
|
const domain = ctx.buildDomain(subdomain);
|
|
const resolveResult = await ctx.dns.universalResolveRecord(domain, 'A');
|
|
let recordIp = ip || 'localhost';
|
|
if (resolveResult) {
|
|
recordIp = resolveResult;
|
|
}
|
|
await ctx.dns.universalDeleteRecord(domain, recordIp);
|
|
results.dns = 'deleted';
|
|
log.info('dns', 'DNS record removal', { result: results.dns });
|
|
} catch (error) {
|
|
results.dns = error.message;
|
|
}
|
|
} else if (!shouldDeleteContainer) {
|
|
results.dns = 'kept (user choice)';
|
|
} else {
|
|
results.dns = 'skipped (no subdomain or token)';
|
|
}
|
|
|
|
if (shouldDeleteContainer && subdomain) {
|
|
try {
|
|
// Check if this service was deployed in subdirectory mode
|
|
const services = await servicesStateManager.read();
|
|
const serviceList = Array.isArray(services) ? services : [];
|
|
const service = serviceList.find(s => s.id === subdomain);
|
|
|
|
if (service?.routingMode === 'subdirectory') {
|
|
// Subdirectory mode: remove handle block from inside main domain block
|
|
const subResult = await helpers.removeSubpathConfig(subdomain);
|
|
results.caddy = subResult?.success ? 'removed' : 'not found';
|
|
} else {
|
|
// Subdomain mode: remove standalone domain block
|
|
const domain = ctx.buildDomain(subdomain);
|
|
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g');
|
|
const caddyResult = await caddy.modify(currentContent => {
|
|
const replaced = currentContent.replace(siteBlockRegex, '\n');
|
|
if (replaced.length === currentContent.length) return null;
|
|
return replaced.replace(/\n{3,}/g, '\n\n');
|
|
});
|
|
results.caddy = caddyResult.success ? 'removed' : (caddyResult.rolledBack ? 'removed (reload failed)' : 'not found');
|
|
}
|
|
log.info('caddy', 'Caddy config removal', { result: results.caddy });
|
|
} catch (error) {
|
|
results.caddy = error.message;
|
|
}
|
|
} else if (!shouldDeleteContainer) {
|
|
results.caddy = 'kept (user choice)';
|
|
}
|
|
|
|
try {
|
|
if (await exists(ctx.SERVICES_FILE)) {
|
|
let removed = false;
|
|
await servicesStateManager.update(services => {
|
|
const initialLength = services.length;
|
|
const filtered = services.filter(s => s.id !== subdomain);
|
|
removed = filtered.length !== initialLength;
|
|
return filtered;
|
|
});
|
|
results.service = removed ? 'removed' : 'not found';
|
|
}
|
|
log.info('deploy', 'Service config removal', { result: results.service });
|
|
} catch (error) {
|
|
results.service = error.message;
|
|
}
|
|
|
|
ok(res, { message: `App ${appId} removal completed`, results });
|
|
} catch (error) {
|
|
await logError('app-removal', error);
|
|
errorResponse(res, 500, ctx.safeErrorMessage(error), { results });
|
|
}
|
|
}, 'apps-delete'));
|
|
|
|
return router;
|
|
};
|