diff --git a/dashcaddy-api/__tests__/disk-settings-loader.test.js b/dashcaddy-api/__tests__/disk-settings-loader.test.js new file mode 100644 index 0000000..2d92cb7 --- /dev/null +++ b/dashcaddy-api/__tests__/disk-settings-loader.test.js @@ -0,0 +1,213 @@ +/** + * DC-048 — disk-settings-loader unit tests + * + * Covers: + * - applies persisted values to process.env (happy path) + * - explicit process.env wins over persisted file + * - missing file → no-op, no throw + * - malformed JSON → no throw, engine defaults preserved + * - non-numeric values rejected, not silently applied + * - empty/null/undefined values skipped + * - idempotent across calls (once-guard) + * - all six mapped keys land in env when persisted + * + * Run with: npx jest __tests__/disk-settings-loader.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Snapshot env at module load so we can restore in afterEach. We always +// UNSET the loader-managed keys (HEALTH_*, AUDIT_*, BACKUP_*, CONTAINER_STATS_*) +// at the start of each test, regardless of whether they were set at +// snapshot time, because the loader mutates process.env and stale values +// from prior tests would silently change behavior. +const LOADER_KEYS = [ + 'HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES', 'HEALTH_HISTORY_RETENTION', + 'AUDIT_MAX_ENTRIES', 'BACKUP_MAX_STORAGE_BYTES', 'CONTAINER_STATS_MAX_ENTRIES', +]; +const ORIGINAL_ENV = Object.fromEntries( + Object.entries(process.env).filter(([k]) => LOADER_KEYS.includes(k) || k === 'DATA_DIR'), +); + +function restoreEnv() { + // Loader-managed keys: ALWAYS reset to ORIGINAL_ENV state (or undefined). + // This is critical — without it, env vars set by a prior test would leak + // into the next test as "env-already-set" and the loader would skip + // values that the test expects to be applied. + for (const k of LOADER_KEYS) { + if (ORIGINAL_ENV[k] === undefined) { + delete process.env[k]; + } else { + process.env[k] = ORIGINAL_ENV[k]; + } + } + delete process.env.DATA_DIR; +} + +// Temp data dir for filesystem-driven tests. +const TMP_DATA_DIR = '/tmp/dashcaddy-disk-settings-loader-test'; +function makeDataDir() { + try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ } + fs.mkdirSync(TMP_DATA_DIR, { recursive: true }); +} +function writePersisted(obj) { + fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), JSON.stringify(obj)); +} + +describe('disk-settings-loader', () => { + beforeEach(() => { + restoreEnv(); + makeDataDir(); + // Wipe the once-guard between tests so each case sees a fresh loader run. + // We must require the module AFTER clearing the cache. + delete require.cache[require.resolve('../src/config/disk-settings-loader')]; + const loader = require('../src/config/disk-settings-loader'); + loader._resetForTesting(); + // Force hasRun reset (jest's module loader is not always cleared by the + // require.cache delete — explicit call is the contract for the loader). + // Note: loader._resetForTesting is the authoritative reset path. + }); + + afterAll(() => { + restoreEnv(); + try { fs.rmSync(TMP_DATA_DIR, { recursive: true, force: true }); } catch (_) { /* ok */ } + }); + + it('applies all six persisted values to process.env', () => { + writePersisted({ + healthCheckInterval: 45000, + healthMaxEntries: 750, + healthRetentionDays: 14, + statsMaxEntries: 800, + auditMaxEntries: 1500, + backupMaxStorageBytes: 2147483648, + }); + + const loader = require('../src/config/disk-settings-loader'); + const result = loader({ dataDir: TMP_DATA_DIR }); + + expect(result.applied).toHaveLength(6); + expect(process.env.HEALTH_CHECK_INTERVAL).toBe('45000'); + expect(process.env.HEALTH_MAX_ENTRIES).toBe('750'); + expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14'); + expect(process.env.CONTAINER_STATS_MAX_ENTRIES).toBe('800'); + expect(process.env.AUDIT_MAX_ENTRIES).toBe('1500'); + expect(process.env.BACKUP_MAX_STORAGE_BYTES).toBe('2147483648'); + expect(result.skipped).toEqual([]); + }); + + it('does not throw when disk-settings.json is missing', () => { + // TMP_DATA_DIR exists but no disk-settings.json inside it. + const loader = require('../src/config/disk-settings-loader'); + expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow(); + const result = loader({ dataDir: TMP_DATA_DIR }); // idempotent + expect(result.applied).toEqual([]); + }); + + it('does not throw on malformed JSON; logs to stderr', () => { + fs.writeFileSync(path.join(TMP_DATA_DIR, 'disk-settings.json'), '{ this is not json'); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const loader = require('../src/config/disk-settings-loader'); + expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow(); + const result = loader({ dataDir: TMP_DATA_DIR }); + expect(result.applied).toEqual([]); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('WARN: failed to parse'), + ); + stderrSpy.mockRestore(); + }); + + it('explicit process.env wins over persisted file', () => { + process.env.HEALTH_HISTORY_RETENTION = '90'; + writePersisted({ + healthRetentionDays: 7, + healthMaxEntries: 999, + }); + + const loader = require('../src/config/disk-settings-loader'); + const result = loader({ dataDir: TMP_DATA_DIR }); + + expect(process.env.HEALTH_HISTORY_RETENTION).toBe('90'); // unchanged + expect(process.env.HEALTH_MAX_ENTRIES).toBe('999'); // applied + expect(result.skipped).toEqual([ + expect.objectContaining({ envKey: 'HEALTH_HISTORY_RETENTION', reason: 'env-already-set' }), + ]); + }); + + it('rejects non-numeric values for numeric fields', () => { + writePersisted({ + healthCheckInterval: 'fast', // not numeric + healthMaxEntries: '500x', // not numeric + healthRetentionDays: 14, // valid + auditMaxEntries: null, // silently skipped (null) + backupMaxStorageBytes: '', // silently skipped (empty) + }); + + const loader = require('../src/config/disk-settings-loader'); + const result = loader({ dataDir: TMP_DATA_DIR }); + + // Only the valid value lands in `applied`. + expect(result.applied.map((a) => a.envKey)).toEqual(['HEALTH_HISTORY_RETENTION']); + // Non-numeric values appear in `skipped` with reason='non-numeric'. + // null and '' are silently filtered (treated as "field not present"). + expect(result.skipped.map((s) => s.envKey).sort()).toEqual( + ['HEALTH_CHECK_INTERVAL', 'HEALTH_MAX_ENTRIES'].sort(), + ); + expect(result.skipped.every((s) => s.reason === 'non-numeric')).toBe(true); + }); + + it('coerces numeric strings (e.g. "14") to integer strings', () => { + writePersisted({ healthRetentionDays: '14' }); + const loader = require('../src/config/disk-settings-loader'); + loader({ dataDir: TMP_DATA_DIR }); + expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14'); + // Must be an integer-formatted string (not "14.7", "14x", etc.) + expect(Number.isInteger(parseInt(process.env.HEALTH_HISTORY_RETENTION, 10))).toBe(true); + }); + + it('is idempotent across multiple calls (once-guard)', () => { + writePersisted({ healthRetentionDays: 7 }); + const loader = require('../src/config/disk-settings-loader'); + const first = loader({ dataDir: TMP_DATA_DIR }); + const second = loader({ dataDir: TMP_DATA_DIR }); + expect(first.applied).toHaveLength(1); + expect(second.applied).toEqual([]); + expect(second.alreadyRun).toBe(true); + }); + + it('skips unknown fields without crashing', () => { + writePersisted({ + healthRetentionDays: 14, + unknownField: 'whatever', + anotherUnknown: { nested: true }, + }); + const loader = require('../src/config/disk-settings-loader'); + expect(() => loader({ dataDir: TMP_DATA_DIR })).not.toThrow(); + expect(process.env.HEALTH_HISTORY_RETENTION).toBe('14'); + }); + + it('returns a summary object with source path', () => { + writePersisted({ healthRetentionDays: 14 }); + const loader = require('../src/config/disk-settings-loader'); + const result = loader({ dataDir: TMP_DATA_DIR }); + expect(result.source).toBe(path.join(TMP_DATA_DIR, 'disk-settings.json')); + expect(result.alreadyRun).toBe(false); + }); + + it('writes a boot summary to stderr when no logger is provided', () => { + writePersisted({ healthRetentionDays: 14, healthMaxEntries: 999 }); + const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const loader = require('../src/config/disk-settings-loader'); + loader({ dataDir: TMP_DATA_DIR }); // no logger passed + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringMatching(/^\[disk-settings-loader\] rehydrated 2 setting/), + ); + stderrSpy.mockRestore(); + }); +}); diff --git a/dashcaddy-api/routes/disk-settings.js b/dashcaddy-api/routes/disk-settings.js index 8ebf0bd..400bbe0 100644 --- a/dashcaddy-api/routes/disk-settings.js +++ b/dashcaddy-api/routes/disk-settings.js @@ -2,6 +2,12 @@ const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); +const platformPaths = require('../platform-paths'); + +// DC-048 — the canonical disk-settings.json path. Shared by GET + POST. +function getSettingsFile() { + return path.join(platformPaths.dataDir, 'disk-settings.json'); +} // GET current disk settings + actual disk usage router.get('/', (req, res) => { @@ -9,7 +15,10 @@ router.get('/', (req, res) => { const settings = { healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000'), healthMaxEntries: parseInt(process.env.HEALTH_MAX_ENTRIES || '500'), - healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '14'), + // DC-048 — align route default to engine default (health-checker.js:34 + // reads 30 from env when unset; the route previously showed 14 as the + // "no override" value, which silently disagreed with the engine). + healthRetentionDays: parseInt(process.env.HEALTH_HISTORY_RETENTION || '30'), statsMaxEntries: parseInt(process.env.CONTAINER_STATS_MAX_ENTRIES || '500'), auditMaxEntries: parseInt(process.env.AUDIT_MAX_ENTRIES || '1000'), backupMaxStorageBytes: parseInt(process.env.BACKUP_MAX_STORAGE_BYTES || '0'), @@ -31,7 +40,7 @@ router.get('/', (req, res) => { } catch {} // Load persisted settings - const settingsFile = path.join(path.dirname(require('../config/paths').configFile), 'disk-settings.json'); + const settingsFile = getSettingsFile(); let persisted = {}; try { persisted = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {} @@ -45,24 +54,37 @@ router.get('/', (req, res) => { router.post('/', (req, res) => { try { const { healthInterval, healthMaxEntries, healthRetentionDays, statsMaxEntries, auditMaxEntries } = req.body; + + // DC-048 — coerce + validate EVERY numeric input before persisting. + // Without this gate, parseInt('abc') === NaN → String(NaN) === 'NaN' → + // process.env.HEALTH_CHECK_INTERVAL becomes 'NaN' at runtime AND the + // persisted file gets JSON.stringify({x: NaN}) === {"x": null} which + // the loader silently drops on next boot. Validation now rejects the + // request with 400 BEFORE any env mutation or file write. + const intField = (name, value) => { + const n = Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n)) { + throw new Error(`${name} must be an integer (received ${JSON.stringify(value)})`); + } + return n; + }; + const updates = {}; - - if (healthInterval !== undefined) { updates.healthCheckInterval = parseInt(healthInterval); process.env.HEALTH_CHECK_INTERVAL = String(healthInterval); } - if (healthMaxEntries !== undefined) { updates.healthMaxEntries = parseInt(healthMaxEntries); process.env.HEALTH_MAX_ENTRIES = String(healthMaxEntries); } - if (healthRetentionDays !== undefined) { updates.healthRetentionDays = parseInt(healthRetentionDays); process.env.HEALTH_HISTORY_RETENTION = String(healthRetentionDays); } - if (statsMaxEntries !== undefined) { updates.statsMaxEntries = parseInt(statsMaxEntries); process.env.CONTAINER_STATS_MAX_ENTRIES = String(statsMaxEntries); } - if (auditMaxEntries !== undefined) { updates.auditMaxEntries = parseInt(auditMaxEntries); process.env.AUDIT_MAX_ENTRIES = String(auditMaxEntries); } - + if (healthInterval !== undefined) { const n = intField('healthInterval', healthInterval); updates.healthCheckInterval = n; process.env.HEALTH_CHECK_INTERVAL = String(n); } + if (healthMaxEntries !== undefined) { const n = intField('healthMaxEntries', healthMaxEntries); updates.healthMaxEntries = n; process.env.HEALTH_MAX_ENTRIES = String(n); } + if (healthRetentionDays !== undefined) { const n = intField('healthRetentionDays', healthRetentionDays); updates.healthRetentionDays = n; process.env.HEALTH_HISTORY_RETENTION = String(n); } + if (statsMaxEntries !== undefined) { const n = intField('statsMaxEntries', statsMaxEntries); updates.statsMaxEntries = n; process.env.CONTAINER_STATS_MAX_ENTRIES = String(n); } + if (auditMaxEntries !== undefined) { const n = intField('auditMaxEntries', auditMaxEntries); updates.auditMaxEntries = n; process.env.AUDIT_MAX_ENTRIES = String(n); } + // Persist to file - const paths = require('../config/paths'); - const settingsFile = path.join(path.dirname(paths.configFile), 'disk-settings.json'); + const settingsFile = getSettingsFile(); let existing = {}; try { existing = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); } catch {} fs.writeFileSync(settingsFile, JSON.stringify({ ...existing, ...updates }, null, 2)); - + res.json({ success: true, updated: updates, message: 'Settings saved. Some changes apply on next container restart.' }); } catch (e) { - res.status(500).json({ success: false, error: e.message }); + res.status(e.statusCode || 400).json({ success: false, error: e.message }); } }); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 8d9e66f..0d786ac 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -19,6 +19,11 @@ const { asyncHandler } = require('./utils/async-handler'); // Managers and utilities const StateManager = require('./managers/state-manager'); const platformPaths = require('../platform-paths'); +// DC-048 — rehydrate process.env from disk-settings.json BEFORE any engine +// module reads env at module-load time. Must run before health-checker, +// audit-logger, and the backups route module (backups.js reads +// BACKUP_MAX_STORAGE_BYTES at module load too). +require('./config/disk-settings-loader')(); const { LicenseManager } = require('./managers/license-manager'); const credentialManager = require('./managers/credential-manager'); const authManager = require('./managers/auth-manager'); diff --git a/dashcaddy-api/src/config/disk-settings-loader.js b/dashcaddy-api/src/config/disk-settings-loader.js new file mode 100644 index 0000000..b64ff0c --- /dev/null +++ b/dashcaddy-api/src/config/disk-settings-loader.js @@ -0,0 +1,193 @@ +/** + * 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; diff --git a/status/js/disk-settings.js b/status/js/disk-settings.js index 65ae14c..73aaa31 100644 --- a/status/js/disk-settings.js +++ b/status/js/disk-settings.js @@ -59,7 +59,7 @@
${settingRow('Health Check Interval', 'healthInterval', c.healthCheckInterval, 'seconds', Math.round((c.healthCheckInterval||30000)/1000), 5, 300)} ${settingRow('Max Health Entries/Service', 'healthMaxEntries', c.healthMaxEntries, 'entries', c.healthMaxEntries||500, 50, 5000)} - ${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||14, 1, 90)} + ${settingRow('Health Retention', 'healthRetentionDays', c.healthRetentionDays, 'days', c.healthRetentionDays||30, 1, 90)} ${settingRow('Max Stats Entries', 'statsMaxEntries', c.statsMaxEntries, 'entries', c.statsMaxEntries||500, 100, 10000)} ${settingRow('Max Audit Entries', 'auditMaxEntries', c.auditMaxEntries, 'entries', c.auditMaxEntries||1000, 100, 10000)}