const express = require('express'); const { success, error: errorResponse } = require('../src/utils/responses'); const { ValidationError } = require('../src/utilities/errors'); /** * Disk space management routes * * GET /disk — current usage snapshot (budget, breakdown, status) * GET /disk/breakdown — detailed breakdown incl. per-container log sizes * GET /disk/config — get disk budget settings * POST /disk/config — update disk budget settings * POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only) */ // DC-059: monotonic-ordering invariant for the three threshold percentages. // DiskSpaceMonitor._getBudgetStatus() walks them in order // (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and // returns at the FIRST threshold the usage crosses. If a caller writes // them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60), // the higher-priority branches become unreachable and the monitor silently // misclassifies budget state. Validate against the *effective* config // (current value + incoming update for each field) so partial updates can // be applied one field at a time without violating the invariant. // // Clamp values to the same ranges the previous inline Math.min/Math.max // chains enforced (warning 50..99, critical 60..99, aggressive 70..99) // so we don't loosen the original bounds while adding the new check. const THRESHOLD_BOUNDS = Object.freeze({ warning: { min: 50, max: 99 }, critical: { min: 60, max: 99 }, aggressive: { min: 70, max: 99 }, }); function clampThreshold(name, value) { const { min, max } = THRESHOLD_BOUNDS[name]; return Math.min(Math.max(value, min), max); } /** * Apply a candidate update to a baseline config, then verify the three * threshold percentages still satisfy * warningThresholdPct < criticalThresholdPct < cleanupAggressivePct. * The POST /config endpoint accepts partial updates (single field at a * time), so we merge into the live diskSpaceMonitor config first, then test * the merged value. Returns the merged candidate on success; throws * ValidationError if the ordering invariant would be violated. * * @param {Object} baseline - current effective config from diskSpaceMonitor * @param {Object} candidate - the partial update being applied this request * @returns {Object} merged candidate with thresholds clamped to bounds */ function mergeAndCheckOrdering(baseline, candidate) { const next = { ...baseline }; if (typeof candidate.warningThresholdPct === 'number') { next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct); } if (typeof candidate.criticalThresholdPct === 'number') { next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct); } if (typeof candidate.cleanupAggressivePct === 'number') { next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct); } if (!(next.warningThresholdPct < next.criticalThresholdPct)) { throw new ValidationError( `warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`, 'warningThresholdPct' ); } if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) { throw new ValidationError( `criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`, 'criticalThresholdPct' ); } // Return only the fields the caller asked to change (preserves partial- // update semantics; diskSpaceMonitor.configure does its own merge). const out = {}; if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct; if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct; if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct; return out; } module.exports = function({ diskSpaceMonitor, asyncHandler, log }) { const router = express.Router(); // Current disk usage snapshot router.get('/', asyncHandler(async (req, res) => { const snapshot = await diskSpaceMonitor.getSnapshot(); success(res, snapshot); }, 'disk-get')); // Detailed breakdown (includes per-container log sizes) router.get('/breakdown', asyncHandler(async (req, res) => { const breakdown = await diskSpaceMonitor.getDetailedBreakdown(); success(res, breakdown); }, 'disk-breakdown')); // Get disk budget config router.get('/config', asyncHandler(async (req, res) => { success(res, diskSpaceMonitor.getConfig()); }, 'disk-config-get')); // Update disk budget config router.post('/config', asyncHandler(async (req, res) => { const { diskBudgetGB, warningThresholdPct, criticalThresholdPct, autoCleanup, enabled, cleanupAggressivePct } = req.body; const updates = {}; if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000); // DC-059: threshold percentages must satisfy a strict monotonic order // (warning < critical < aggressive) so _getBudgetStatus() reaches the // correct branch. mergeAndCheckOrdering() validates against the live // baseline, so partial updates that violate the invariant are rejected // BEFORE we mutate diskSpaceMonitor.diskConfig. const thresholdUpdates = mergeAndCheckOrdering( diskSpaceMonitor.getConfig(), { warningThresholdPct, criticalThresholdPct, cleanupAggressivePct } ); Object.assign(updates, thresholdUpdates); if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup; if (typeof enabled === 'boolean') updates.enabled = enabled; const config = diskSpaceMonitor.configure(updates); log.info('disk', 'Disk budget updated', updates); success(res, { message: 'Disk budget updated', config }); }, 'disk-config-set')); // Manual cleanup trigger router.post('/cleanup', asyncHandler(async (req, res) => { const level = req.body?.level || 'standard'; if (!['standard', 'aggressive', 'logs-only'].includes(level)) { return errorResponse(res, 'Invalid cleanup level. Use: standard, aggressive, or logs-only', 400); } log.info('disk', 'Manual cleanup triggered', { level, by: req.auth?.user || 'api' }); const result = await diskSpaceMonitor.performCleanup(level); success(res, result); }, 'disk-cleanup')); return router; };