Add config migration system
When config.json schema changes between versions, register a migration function in src/config/migrations.js. On startup, loadSiteConfig() detects the stored version, runs all migrations forward, and writes the result back. Users never see the migration — it runs silently and the rest of the app only ever sees the current schema. Includes: - v0 → v1: normalize dns from string to object - v1 → v2: add dns.provider field (default 'technitium') - Forward compat: configs from future versions left untouched - Idempotent: re-running on already-migrated config is a no-op - Safe: no user data is removed during migration 21 unit tests covering edge cases: null input, forward compat, corrupt JSON, missing parent dirs, idempotency, full migration chain.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Config migration system
|
||||
*
|
||||
* When config.json schema changes between versions, register a migration
|
||||
* function here. On load, the loader detects the stored version, runs all
|
||||
* migrations from that version forward, and writes the result back.
|
||||
*
|
||||
* Migration format:
|
||||
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
|
||||
*
|
||||
* Each migration is responsible for transforming the previous version's
|
||||
* shape into the next version's shape. They run sequentially, so v1→v2→v3
|
||||
* all execute in order.
|
||||
*
|
||||
* For first-time users with no config file, the loader creates a fresh
|
||||
* config with CURRENT_VERSION, so they start at the latest schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Migrations: keys are the version they PRODUCE.
|
||||
* Each migration takes a raw config object and returns the next version.
|
||||
*/
|
||||
const migrations = {
|
||||
// v0 (unversioned) → v1: add _version field, normalize dns structure
|
||||
1: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (!migrated._version) migrated._version = 1;
|
||||
// Normalize: older configs may have dns as a string IP, convert to object
|
||||
if (typeof migrated.dns === 'string') {
|
||||
migrated.dns = { ip: migrated.dns, port: 5380 };
|
||||
} else if (!migrated.dns) {
|
||||
migrated.dns = { ip: '', port: 5380 };
|
||||
}
|
||||
return migrated;
|
||||
},
|
||||
|
||||
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
|
||||
2: (raw) => {
|
||||
const migrated = { ...raw };
|
||||
if (migrated.dns && !migrated.dns.provider) {
|
||||
migrated.dns.provider = 'technitium';
|
||||
}
|
||||
migrated._version = 2;
|
||||
return migrated;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
|
||||
* @param {object} raw - The raw config object (may or may not have _version)
|
||||
* @returns {object} The migrated config
|
||||
*/
|
||||
function migrate(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
// First-time load: return minimal config at current version
|
||||
return { _version: CURRENT_VERSION };
|
||||
}
|
||||
|
||||
const fromVersion = raw._version || 0;
|
||||
if (fromVersion > CURRENT_VERSION) {
|
||||
// Config from a future version — bail out, don't corrupt it
|
||||
// The validation step will catch any actual issues
|
||||
return raw;
|
||||
}
|
||||
|
||||
let current = { ...raw };
|
||||
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
|
||||
if (migrations[v]) {
|
||||
current = migrations[v](current);
|
||||
} else {
|
||||
// No migration defined for this version, just bump _version
|
||||
current._version = v;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from disk, run migrations if needed, and write back the
|
||||
* migrated version. Safe to call on every startup.
|
||||
* @param {string} configFile - Absolute path to config.json
|
||||
* @param {object} log - Logger instance
|
||||
* @returns {object} The migrated config object
|
||||
*/
|
||||
function loadAndMigrate(configFile, log) {
|
||||
let raw = null;
|
||||
let fileExisted = false;
|
||||
|
||||
if (fs.existsSync(configFile)) {
|
||||
fileExisted = true;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
} catch (e) {
|
||||
if (log && log.error) {
|
||||
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
|
||||
}
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
|
||||
const fromVersion = raw && raw._version ? raw._version : 0;
|
||||
const migrated = migrate(raw);
|
||||
|
||||
// Only write back to disk if:
|
||||
// 1. The file already existed (we don't create configs on fresh installs —
|
||||
// the loader's defaults handle that case), AND
|
||||
// 2. The version actually changed (no point rewriting identical content)
|
||||
if (fileExisted && fromVersion < CURRENT_VERSION) {
|
||||
if (log && log.info) {
|
||||
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
|
||||
from: fromVersion,
|
||||
to: CURRENT_VERSION,
|
||||
path: configFile
|
||||
});
|
||||
}
|
||||
// Write back the migrated config
|
||||
try {
|
||||
// Ensure parent dir exists
|
||||
const dir = path.dirname(configFile);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
|
||||
} catch (e) {
|
||||
if (log && log.warn) {
|
||||
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
/**
|
||||
* Site configuration loader
|
||||
* Loads and manages site-wide settings from config.json
|
||||
*
|
||||
* Includes automatic migration from older config versions (see migrations.js).
|
||||
* Users never see the migration — it runs silently on startup, writes the
|
||||
* updated config back, and the rest of the app only ever sees the current
|
||||
* schema.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const { validateConfig } = require('../../config-schema');
|
||||
const { CADDY } = require('../../constants');
|
||||
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
|
||||
|
||||
const siteConfig = {
|
||||
tld: '.home',
|
||||
@@ -21,9 +26,11 @@ const siteConfig = {
|
||||
|
||||
function loadSiteConfig(CONFIG_FILE, log) {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
// Run migrations first — this handles config.json files from older
|
||||
// versions of DashCaddy and writes the migrated version back to disk.
|
||||
const raw = loadAndMigrate(CONFIG_FILE, log);
|
||||
|
||||
if (raw && Object.keys(raw).length > 0) {
|
||||
// Validate config and log any issues
|
||||
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
|
||||
if (log && log.warn) {
|
||||
@@ -76,4 +83,5 @@ module.exports = {
|
||||
loadSiteConfig,
|
||||
buildDomain,
|
||||
buildServiceUrl,
|
||||
CURRENT_VERSION
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user