feat(core/shutdown): ✨ Enhance shutdown feedback & Docker container cleanup during graceful exits
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fa539a1ab0
commit
b8ab200016
3 changed files with 84 additions and 1 deletions
|
|
@ -31,6 +31,9 @@ export class ShutdownDisplay {
|
|||
case 'host-services':
|
||||
this.renderServiceEvent(event);
|
||||
break;
|
||||
case 'orphan-cleanup':
|
||||
this.renderOrphanEvent(event);
|
||||
break;
|
||||
case 'docker-containers':
|
||||
this.renderDockerEvent(event);
|
||||
break;
|
||||
|
|
@ -74,6 +77,9 @@ export class ShutdownDisplay {
|
|||
if (result.hostServices.failed.length > 0) {
|
||||
parts.push(`${colors.error('✗')} Failed: ${result.hostServices.failed.length}`);
|
||||
}
|
||||
if (result.orphans.removed.length > 0) {
|
||||
parts.push(`${colors.warning('⚠')} Orphans removed: ${result.orphans.removed.length}`);
|
||||
}
|
||||
if (result.containers.stoppedCount > 0) {
|
||||
const volText = result.containers.volumesRemoved ? ' (volumes removed)' : '';
|
||||
parts.push(`${colors.healthy('●')} Containers: ${result.containers.stoppedCount}${volText}`);
|
||||
|
|
@ -127,6 +133,13 @@ export class ShutdownDisplay {
|
|||
console.log(` ${statusText} ${paddedId}${durationText}`);
|
||||
}
|
||||
|
||||
private renderOrphanEvent(event: ShutdownProgressEvent): void {
|
||||
if (event.message.startsWith('Checking')) return;
|
||||
console.log('');
|
||||
console.log(colors.accent(` ▸ Orphan Cleanup`));
|
||||
console.log(` ${colors.warning('⚠')} ${event.message}`);
|
||||
}
|
||||
|
||||
private renderDockerEvent(event: ShutdownProgressEvent): void {
|
||||
if (event.message.includes('Stopping')) {
|
||||
console.log('');
|
||||
|
|
|
|||
|
|
@ -459,6 +459,44 @@ export class DockerOps {
|
|||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and remove orphaned feature containers that were started from
|
||||
* feature-level compose files (codebase/features/*/docker-compose.yml)
|
||||
* but not managed by the deployment compose. These use the old naming
|
||||
* pattern ({feature}-{service}) without the lilith- prefix and can
|
||||
* cause port conflicts with the deployment compose containers.
|
||||
*/
|
||||
async removeOrphanedFeatureContainers(): Promise<string[]> {
|
||||
const featurePattern = /^(attributes|conversation-assistant|email|feature-flags|i18n|image-assistant|landing|marketplace|media|merchant|messaging|payments|platform-admin|profile|seo|sso|truth-validation)-(postgres|redis|minio|db|text-service)$/;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker ps -a --format '{{.Names}}'`,
|
||||
{ cwd: this.config.projectRoot },
|
||||
);
|
||||
|
||||
const orphans = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(name => name && featurePattern.test(name));
|
||||
|
||||
if (orphans.length === 0) return [];
|
||||
|
||||
for (const name of orphans) {
|
||||
try {
|
||||
await execAsync(`docker rm -f ${name}`, { cwd: this.config.projectRoot });
|
||||
this.logger.info(`Removed orphaned container: ${name}`);
|
||||
} catch {
|
||||
this.logger.warn(`Failed to remove orphaned container: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return orphans;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expected container service names for given profiles
|
||||
* Uses `docker compose config --services` to list what should be running
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { RunConfig } from '../utils/config';
|
|||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type ShutdownPhase = 'discovery' | 'host-services' | 'docker-containers' | 'cleanup';
|
||||
export type ShutdownPhase = 'discovery' | 'orphan-cleanup' | 'host-services' | 'docker-containers' | 'cleanup';
|
||||
|
||||
export type ServiceStopStatus = 'stopping' | 'stopped' | 'force-killed' | 'failed' | 'not-running';
|
||||
|
||||
|
|
@ -47,6 +47,10 @@ export interface ShutdownResult {
|
|||
notRunning: string[];
|
||||
durationMs: number;
|
||||
};
|
||||
orphans: {
|
||||
removed: string[];
|
||||
durationMs: number;
|
||||
};
|
||||
containers: {
|
||||
discovered: ContainerStatus[];
|
||||
stoppedCount: number;
|
||||
|
|
@ -92,6 +96,26 @@ export class ShutdownOrchestrator {
|
|||
message: `Found ${runningServiceIds.length} host services, ${containerStatuses.length} containers`,
|
||||
});
|
||||
|
||||
// ── Phase 1b: Orphan Cleanup ──────────────────────────────────────────
|
||||
const orphanStart = Date.now();
|
||||
let orphansRemoved: string[] = [];
|
||||
|
||||
emit?.({
|
||||
phase: 'orphan-cleanup',
|
||||
message: 'Checking for orphaned feature containers...',
|
||||
});
|
||||
|
||||
orphansRemoved = await this.docker.removeOrphanedFeatureContainers();
|
||||
|
||||
if (orphansRemoved.length > 0) {
|
||||
emit?.({
|
||||
phase: 'orphan-cleanup',
|
||||
message: `Removed ${orphansRemoved.length} orphaned containers: ${orphansRemoved.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const orphanDuration = Date.now() - orphanStart;
|
||||
|
||||
// Short-circuit if nothing is running
|
||||
if (runningServiceIds.length === 0 && containerStatuses.length === 0) {
|
||||
return {
|
||||
|
|
@ -103,6 +127,10 @@ export class ShutdownOrchestrator {
|
|||
notRunning: [],
|
||||
durationMs: 0,
|
||||
},
|
||||
orphans: {
|
||||
removed: orphansRemoved,
|
||||
durationMs: orphanDuration,
|
||||
},
|
||||
containers: {
|
||||
discovered: [],
|
||||
stoppedCount: 0,
|
||||
|
|
@ -197,6 +225,10 @@ export class ShutdownOrchestrator {
|
|||
notRunning,
|
||||
durationMs: serviceDuration,
|
||||
},
|
||||
orphans: {
|
||||
removed: orphansRemoved,
|
||||
durationMs: orphanDuration,
|
||||
},
|
||||
containers: {
|
||||
discovered: containerStatuses,
|
||||
stoppedCount: containerStatuses.length,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue