DC-005: Fix all 138 broken test paths after src/ refactor
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Dependency Manager - Service dependency tracking with ordered restart chains
|
||||
*
|
||||
* Manages directed acyclic graph (DAG) of service dependencies. Services can
|
||||
* declare which other services they depend on, and this manager provides:
|
||||
* - Full dependency graph inspection
|
||||
* - Topological ordering for safe restart chains
|
||||
* - Circular dependency detection
|
||||
* - Health-aware restart with per-service polling
|
||||
*
|
||||
* Dependencies are stored directly on service objects in services.json:
|
||||
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
|
||||
*
|
||||
* @module dependency-manager
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/** Maximum seconds to wait for a single container to become healthy after restart */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Interval between container health polls */
|
||||
const HEALTH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServiceNode
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {string|null} containerId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyEdge
|
||||
* @property {string} from - The service that depends
|
||||
* @property {string} to - The service being depended upon
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyGraph
|
||||
* @property {ServiceNode[]} nodes
|
||||
* @property {DependencyEdge[]} edges
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyStatusEntry
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {boolean} isUp
|
||||
* @property {string} [error]
|
||||
*/
|
||||
|
||||
/**
|
||||
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `dependency-restart-start` ({ serviceId, chain: string[] })
|
||||
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
|
||||
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
|
||||
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
class DependencyManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Application context
|
||||
* @param {Object} ctx.servicesStateManager - StateManager for services.json
|
||||
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
/** @private */
|
||||
this.ctx = ctx;
|
||||
/** @private */
|
||||
this._servicesStateManager = ctx.servicesStateManager;
|
||||
/** @private */
|
||||
this._docker = ctx.docker;
|
||||
/** @private */
|
||||
this._notification = ctx.notification;
|
||||
/** @private */
|
||||
this._log = ctx.log || console;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load all services from the state manager.
|
||||
* @private
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async _loadServices() {
|
||||
const data = await this._servicesStateManager.read();
|
||||
return Array.isArray(data) ? data : (data.services || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single service by ID.
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async _findService(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.find(s => s.id === serviceId) || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the full dependency graph for visualisation.
|
||||
*
|
||||
* @returns {Promise<DependencyGraph>}
|
||||
*/
|
||||
async getDependencyGraph() {
|
||||
const services = await this._loadServices();
|
||||
|
||||
const nodes = services.map(s => ({
|
||||
serviceId: s.id,
|
||||
name: s.name,
|
||||
containerId: s.containerId || null,
|
||||
}));
|
||||
|
||||
const edges = [];
|
||||
for (const service of services) {
|
||||
const deps = service.dependsOn || [];
|
||||
for (const depId of deps) {
|
||||
edges.push({ from: service.id, to: depId });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the services that depend on the given service (reverse deps).
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
|
||||
*/
|
||||
async getDependents(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.filter(s => (s.dependsOn || []).includes(serviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the direct dependencies for a service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
|
||||
*/
|
||||
async getDependencies(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) return [];
|
||||
const depIds = service.dependsOn || [];
|
||||
return services.filter(s => depIds.includes(s.id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an adjacency list for the current dependency graph.
|
||||
* Edge direction: service → its dependencies (i.e. what it depends on).
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} services
|
||||
* @returns {Map<string, string[]>}
|
||||
*/
|
||||
_buildAdjacencyList(services) {
|
||||
const adj = new Map();
|
||||
for (const service of services) {
|
||||
adj.set(service.id, (service.dependsOn || []).slice());
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFS-based topological sort with cycle detection (white/gray/black coloring).
|
||||
*
|
||||
* Returns services in restart order: dependencies first, dependents last.
|
||||
* The target service is included at the end.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId - Target service (will be last in the result).
|
||||
* @param {Object[]} services - All services.
|
||||
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
|
||||
* @returns {string[]} Ordered service IDs for restart.
|
||||
* @throws {Error} If a circular dependency is detected.
|
||||
*/
|
||||
_topologicalSort(serviceId, services, adj) {
|
||||
// Collect only the reachable sub-graph from serviceId
|
||||
const visited = new Set();
|
||||
const reachable = new Set();
|
||||
|
||||
const collectReachable = (id) => {
|
||||
if (reachable.has(id)) return;
|
||||
reachable.add(id);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
collectReachable(dep);
|
||||
}
|
||||
};
|
||||
collectReachable(serviceId);
|
||||
|
||||
// DFS topological sort on the reachable sub-graph
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const id of reachable) color.set(id, WHITE);
|
||||
|
||||
const result = [];
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
result.push(id);
|
||||
};
|
||||
|
||||
// Visit the target last so it ends up at the end of the result
|
||||
// Actually, we want deps *first* then the target.
|
||||
// The DFS naturally puts deps before dependents, so starting from
|
||||
// serviceId will place it last (which is correct for restart order).
|
||||
dfs(serviceId);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the topologically ordered restart chain for a service.
|
||||
*
|
||||
* The returned array lists all services that must be restarted,
|
||||
* starting with leaf dependencies and ending with the target service.
|
||||
*
|
||||
* @param {string} serviceId - The service to build the chain for.
|
||||
* @returns {Promise<string[]>} Ordered service IDs.
|
||||
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
|
||||
*/
|
||||
async getOrderedRestartChain(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
const adj = this._buildAdjacencyList(services);
|
||||
return this._topologicalSort(serviceId, services, adj);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a proposed set of dependencies for a service.
|
||||
*
|
||||
* Checks:
|
||||
* - All referenced service IDs exist.
|
||||
* - Adding these dependencies would not create a circular dependency.
|
||||
* - A service cannot depend on itself.
|
||||
*
|
||||
* @param {string} serviceId - The service to set dependencies on.
|
||||
* @param {string[]} dependsOn - Proposed dependency IDs.
|
||||
* @returns {Promise<{ valid: boolean, errors: string[] }>}
|
||||
*/
|
||||
async validateDependencies(serviceId, dependsOn) {
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
return { valid: false, errors: ['dependsOn must be an array'] };
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const allIds = new Set(services.map(s => s.id));
|
||||
|
||||
// Service must exist
|
||||
if (!allIds.has(serviceId)) {
|
||||
return { valid: false, errors: [`Service "${serviceId}" not found`] };
|
||||
}
|
||||
|
||||
// Self-dependency
|
||||
if (dependsOn.includes(serviceId)) {
|
||||
errors.push(`Service "${serviceId}" cannot depend on itself`);
|
||||
}
|
||||
|
||||
// Existence check
|
||||
for (const depId of dependsOn) {
|
||||
if (!allIds.has(depId)) {
|
||||
errors.push(`Dependency service "${depId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Circular dependency check: temporarily set the proposed dependsOn
|
||||
// and attempt a topological sort.
|
||||
const tempServices = services.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
return { ...s, dependsOn: dependsOn.slice() };
|
||||
}
|
||||
return { ...s };
|
||||
});
|
||||
|
||||
const adj = this._buildAdjacencyList(tempServices);
|
||||
|
||||
// Check every node for cycles with the new edges
|
||||
try {
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const s of tempServices) color.set(s.id, WHITE);
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
};
|
||||
|
||||
for (const s of tempServices) {
|
||||
if (color.get(s.id) === WHITE) {
|
||||
dfs(s.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the current container status for a service and all its transitive dependencies.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<DependencyStatusEntry[]>}
|
||||
* @throws {Error} If `serviceId` doesn't exist.
|
||||
*/
|
||||
async getDependencyStatus(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
// Collect all transitive dependencies via BFS
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
const visited = new Set();
|
||||
const queue = [serviceId];
|
||||
const allRelated = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const svc = serviceMap.get(currentId);
|
||||
if (!svc) continue;
|
||||
|
||||
allRelated.push(svc);
|
||||
|
||||
for (const depId of (svc.dependsOn || [])) {
|
||||
if (!visited.has(depId)) {
|
||||
queue.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query container status for each
|
||||
const results = [];
|
||||
for (const svc of allRelated) {
|
||||
const entry = {
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
isUp: false,
|
||||
};
|
||||
|
||||
if (!svc.containerId) {
|
||||
entry.error = 'No container associated with this service';
|
||||
results.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
const info = await container.inspect();
|
||||
entry.isUp = info.State?.Running === true;
|
||||
} catch (err) {
|
||||
entry.error = err.message || 'Unable to inspect container';
|
||||
}
|
||||
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restart with dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wait for a container to report as running after a restart.
|
||||
*
|
||||
* @private
|
||||
* @param {string} containerId
|
||||
* @param {number} [timeoutMs=30000]
|
||||
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
|
||||
*/
|
||||
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const container = this._docker.client.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
if (info.State?.Running === true) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Container might not be inspectable during restart — keep polling
|
||||
}
|
||||
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a service and all its dependencies in topological order.
|
||||
*
|
||||
* Emits progress events and sends a notification on completion/failure.
|
||||
* This method is designed to be called from the route handler and
|
||||
* **does not throw** — errors are reported via events and notifications.
|
||||
*
|
||||
* @param {string} serviceId - Target service to restart (with deps).
|
||||
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
|
||||
*/
|
||||
async restartWithDependencies(serviceId) {
|
||||
const service = await this._findService(serviceId);
|
||||
if (!service) {
|
||||
const err = new Error(`Service "${serviceId}" not found`);
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
let chain;
|
||||
try {
|
||||
chain = await this.getOrderedRestartChain(serviceId);
|
||||
} catch (err) {
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
|
||||
this._log.info('dependency', 'Starting dependency restart chain', {
|
||||
serviceId,
|
||||
chain,
|
||||
});
|
||||
|
||||
this.emit('dependency-restart-start', { serviceId, chain });
|
||||
|
||||
const results = [];
|
||||
const total = chain.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const currentId = chain[i];
|
||||
const svc = serviceMap.get(currentId);
|
||||
|
||||
this.emit('dependency-restart-progress', {
|
||||
serviceId,
|
||||
currentServiceId: currentId,
|
||||
index: i,
|
||||
total,
|
||||
});
|
||||
|
||||
if (!svc || !svc.containerId) {
|
||||
const msg = !svc
|
||||
? `Service "${currentId}" not found in state`
|
||||
: `Service "${currentId}" has no container — skipping restart`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
await container.restart();
|
||||
|
||||
// Wait for it to come back up
|
||||
const healthy = await this._waitForContainerHealthy(svc.containerId);
|
||||
if (!healthy) {
|
||||
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
|
||||
|
||||
// Abort chain — dependency didn't come back
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
|
||||
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: true });
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error during restart';
|
||||
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
|
||||
error: msg,
|
||||
});
|
||||
results.push({ serviceId: currentId, restarted: false, error: msg });
|
||||
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('dependency-restart-complete', { serviceId, chain, results });
|
||||
await this._notifyRestartResult(serviceId, true, chain, results);
|
||||
return { success: true, chain, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about the restart result.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @param {boolean} success
|
||||
* @param {string[]} chain
|
||||
* @param {Array} results
|
||||
* @param {string} [failedServiceId]
|
||||
*/
|
||||
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
|
||||
if (!this._notification) return;
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
await this._notification.send('dependency-restart-complete', {
|
||||
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
await this._notification.send('dependency-restart-failed', {
|
||||
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
failedServiceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this._log.error('dependency', 'Failed to send restart notification', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DependencyManager;
|
||||
Reference in New Issue
Block a user