fix(api): rehydrate process.env from disk-settings.json on boot (DC-048) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- New src/config/disk-settings-loader.js runs once at boot (require'd into
  src/app.js immediately after platform-paths, BEFORE health-checker /
  audit-logger / routes/backups read env at module-load).
- Routes the persisted values from <dataDir>/disk-settings.json into the
  six env keys the engine captures: HEALTH_CHECK_INTERVAL,
  HEALTH_MAX_ENTRIES, HEALTH_HISTORY_RETENTION, AUDIT_MAX_ENTRIES,
  BACKUP_MAX_STORAGE_BYTES, CONTAINER_STATS_MAX_ENTRIES.
- Explicit process.env values WIN over persisted file (operator override).
- Non-numeric values rejected; null/empty silently skipped; malformed JSON
  logs WARN to stderr and uses engine defaults.
- Fixes pre-existing POST /api/v1/disk-settings MODULE_NOT_FOUND bug: the
  route referenced non-existent '../config/paths'; now uses platform-paths.
- POST now validates every numeric input (intField gate, 400 on NaN/float)
  to prevent NaN→null round-trip data loss.
- Aligns GET default for healthRetentionDays from '14' to '30' so the route
  matches health-checker.js:34 (engine) and the modal's ||30 fallback.
- 10 unit tests covering happy path, idempotency, explicit-env-wins,
  malformed JSON, non-numeric rejection, env restore between tests, and
  stderr boot-summary fallback.

GLM-5.3 round 1: B (route MODULE_NOT_FOUND + MEDIUM POST NaN→null + boot log LOW).
GLM-5.3 round 2: A (round-1 MEDIUM + boot log LOW resolved via intField gate
and unconditional stderr summary; remaining LOWs are non-blocking).

Live: container restart will pick up persisted values; existing users
who saved 14-day retention will see 30-day retention (engine default) on
next container start since their persisted value never took effect
pre-fix anyway.
This commit is contained in:
Hermes
2026-08-17 16:04:18 -07:00
parent 4555d829ac
commit 6d875e4631
5 changed files with 447 additions and 14 deletions
+35 -13
View File
@@ -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 });
}
});