/** * Disk Settings Bootstrap Loader (DC-048) * * Reads /app/data/disk-settings.json (resolved via platform-paths.dataDir) * at boot time and rehydrates process.env values for engine settings that * were previously captured only via in-memory process.env writes on the * POST /api/v1/disk-settings route. * * Why this exists: * health-checker.js, audit-logger.js, and backups.js all read * `process.env.HEALTH_*` / `process.env.AUDIT_MAX_ENTRIES` / * `process.env.BACKUP_MAX_STORAGE_BYTES` at MODULE LOAD. The previous * POST handler only wrote those values to process.env at runtime, so * any value persisted to disk-settings.json was silently discarded on * every container restart. Users who saved "Health Retention = 7 days" * would see 30 days come back at the next boot. * * Behavior: * - Only sets a key if process.env[key] is already UNDEFINED. Explicit * container / compose env still wins on cold boot (so operators can * override via the env without editing disk-settings.json). * - Logs a single INFO line at boot summarizing what was rehydrated. * - Never throws. A missing or malformed disk-settings.json is logged * and ignored — the engine falls back to its compiled-in defaults. * * Order of operations in src/app.js: * require('./config/disk-settings-loader')(); // ← MUST be before any * const healthChecker = require('./monitoring/health-checker'); // engine module * const auditLogger = require('./security/audit-logger'); // that reads env * * Mapping table (mirrors the POST handler in routes/disk-settings.js): * disk-settings.json field → process.env key * healthCheckInterval → HEALTH_CHECK_INTERVAL (ms) * healthMaxEntries → HEALTH_MAX_ENTRIES (entries) * healthRetentionDays → HEALTH_HISTORY_RETENTION (days) * statsMaxEntries → CONTAINER_STATS_MAX_ENTRIES(entries; reserved, no engine consumer yet) * auditMaxEntries → AUDIT_MAX_ENTRIES (entries) * backupMaxStorageBytes → BACKUP_MAX_STORAGE_BYTES (bytes) * * Returns an object describing what was applied — useful for tests + boot logs. */ 'use strict'; const fs = require('fs'); const path = require('path'); const ENV_MAP = Object.freeze({ healthCheckInterval: 'HEALTH_CHECK_INTERVAL', healthMaxEntries: 'HEALTH_MAX_ENTRIES', healthRetentionDays: 'HEALTH_HISTORY_RETENTION', statsMaxEntries: 'CONTAINER_STATS_MAX_ENTRIES', auditMaxEntries: 'AUDIT_MAX_ENTRIES', backupMaxStorageBytes: 'BACKUP_MAX_STORAGE_BYTES', }); // Numeric fields MUST be coerced to integers; a stray string in disk-settings.json // would otherwise land in process.env as a string and the next // parseInt(process.env.X || 'N') in the engine would silently fall back to N // when the value is unparseable. Defensive coercion here keeps the engine // consistent with the values the user just saved. const NUMERIC_FIELDS = Object.freeze([ 'healthCheckInterval', 'healthMaxEntries', 'healthRetentionDays', 'statsMaxEntries', 'auditMaxEntries', 'backupMaxStorageBytes', ]); function loadPersistedSettings(dataDir) { if (!dataDir) return null; const settingsFile = path.join(dataDir, 'disk-settings.json'); if (!fs.existsSync(settingsFile)) return null; try { const raw = fs.readFileSync(settingsFile, 'utf8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed; } return null; } catch (err) { // Log + swallow. The engine's compiled-in defaults are the safe fallback. // Do NOT re-throw — a malformed settings file must not stop the API from booting. process.stderr.write( `[disk-settings-loader] WARN: failed to parse ${settingsFile}: ${err.message}; using engine defaults\n`, ); return null; } } /** * Resolve dataDir WITHOUT importing platform-paths at the top level — the loader * is required very early in app.js, before platform-paths has been fully loaded * by sibling modules. A local require is safe (it's idempotent and side-effect * free — platform-paths is pure constants). */ function resolveDataDir() { try { // eslint-disable-next-line global-require const platformPaths = require('../../platform-paths'); return platformPaths.dataDir; } catch { return process.env.DATA_DIR || '/etc/dashcaddy'; } } function applyToEnv(persisted, { logger } = {}) { const applied = []; const skipped = []; if (!persisted) return { applied, skipped }; for (const [field, envKey] of Object.entries(ENV_MAP)) { if (!Object.prototype.hasOwnProperty.call(persisted, field)) continue; let value = persisted[field]; if (value === null || value === undefined || value === '') continue; if (NUMERIC_FIELDS.includes(field)) { const n = Number(value); if (!Number.isFinite(n)) { skipped.push({ field, envKey, reason: 'non-numeric' }); continue; } value = String(Math.trunc(n)); } else { value = String(value); } if (process.env[envKey] !== undefined && process.env[envKey] !== '') { // Explicit env wins over persisted file. This is the only way operators // can override a saved value without first deleting the file. skipped.push({ field, envKey, reason: 'env-already-set' }); continue; } process.env[envKey] = value; applied.push({ field, envKey, value }); } return { applied, skipped }; } let hasRun = false; /** * Run the loader once. Idempotent — second invocation is a no-op so test * suites that `jest.resetModules()` between cases don't re-apply values * from a stale persisted file across tests. */ function applyDiskSettings(options = {}) { if (hasRun) return { applied: [], skipped: [], alreadyRun: true }; hasRun = true; const dataDir = options.dataDir || resolveDataDir(); const persisted = loadPersistedSettings(dataDir); const { applied, skipped } = applyToEnv(persisted, options); const summary = { applied, skipped, source: persisted ? path.join(dataDir, 'disk-settings.json') : null, alreadyRun: false, }; if (applied.length > 0) { const msg = `[disk-settings-loader] rehydrated ${applied.length} setting(s) from ${summary.source}: ` + applied.map((a) => `${a.field}=${a.value}`).join(', '); // Always emit to stderr at boot — operators need to see rehydration // regardless of whether the app logger is wired yet (the loader runs // at module-load time, before app.js createApp() builds the logger). if (options.logger) options.logger.info(msg); else process.stderr.write(msg + '\n'); } else if (skipped.length === 0 && !persisted) { // No persisted file: silent. (No boot noise when nothing to do.) } else if (skipped.length > 0) { const msg = `[disk-settings-loader] skipped ${skipped.length} setting(s) (env-already-set or non-numeric): ` + skipped.map((s) => `${s.envKey}(${s.reason})`).join(', '); if (options.logger) options.logger.info(msg); else process.stderr.write(msg + '\n'); } return summary; } // Exposed for tests that need to reset the once-guard between cases. function _resetForTesting() { hasRun = false; } module.exports = applyDiskSettings; module.exports.applyDiskSettings = applyDiskSettings; module.exports._resetForTesting = _resetForTesting; module.exports.ENV_MAP = ENV_MAP;