From 016681b77b62dd440f310459b6052ea03da7a4d0 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 19 Oct 2025 20:41:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(migrations):=20add=20migration=20system=20?= =?UTF-8?q?for=20v3=E2=86=92v4=20config=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create abstract BaseMigration class with order, shouldRun(), migrate() - Add MigrationRunner to execute migrations in order - Add Migration v1→v2 (snmp → upsDevices) - Add Migration v3→v4 (upsList → upsDevices) - Update INupstConfig with version field - Update loadConfig() to run migrations automatically - Update saveConfig() to ensure version field and remove legacy fields - Update Docker test scripts to use real UPS data from .nogit/env.json - Remove colors.bright (not available in @std/fmt/colors) Config migrations allow users to jump versions (e.g., v1→v4) with all intermediate migrations running automatically. Version field tracks config format for future migrations. --- test/manualdocker/01-setup-v3-container.sh | 68 ++++++++++++------ .../02-test-v3-to-v4-migration.sh | 19 +---- ts/colors.ts | 1 - ts/daemon.ts | 52 +++++++------- ts/migrations/base-migration.ts | 54 +++++++++++++++ ts/migrations/index.ts | 10 +++ ts/migrations/migration-runner.ts | 69 +++++++++++++++++++ ts/migrations/migration-v1-to-v2.ts | 56 +++++++++++++++ ts/migrations/migration-v3-to-v4.ts | 45 ++++++++++++ 9 files changed, 310 insertions(+), 64 deletions(-) create mode 100644 ts/migrations/base-migration.ts create mode 100644 ts/migrations/index.ts create mode 100644 ts/migrations/migration-runner.ts create mode 100644 ts/migrations/migration-v1-to-v2.ts create mode 100644 ts/migrations/migration-v3-to-v4.ts diff --git a/test/manualdocker/01-setup-v3-container.sh b/test/manualdocker/01-setup-v3-container.sh index 551ab68..e750441 100755 --- a/test/manualdocker/01-setup-v3-container.sh +++ b/test/manualdocker/01-setup-v3-container.sh @@ -53,7 +53,7 @@ docker exec ${CONTAINER_NAME} bash -c " echo "→ Installing prerequisites in container..." docker exec ${CONTAINER_NAME} bash -c " apt-get update -qq - apt-get install -y -qq git curl sudo + apt-get install -y -qq git curl sudo jq " echo "→ Cloning NUPST v3 (commit ${V3_COMMIT})..." @@ -66,35 +66,59 @@ docker exec ${CONTAINER_NAME} bash -c " git log -1 --oneline " -echo "→ Running NUPST v3 installation script..." +echo "→ Running NUPST v3 installation directly (bypassing install.sh auto-update)..." docker exec ${CONTAINER_NAME} bash -c " cd /opt/nupst - bash install.sh -y + # Run setup.sh directly to avoid install.sh trying to update to v4 + bash setup.sh -y " -echo "→ Creating dummy NUPST configuration for testing..." -docker exec ${CONTAINER_NAME} bash -c " - mkdir -p /etc/nupst - cat > /etc/nupst/config.json << 'EOF' +echo "→ Creating NUPST configuration using real UPS data from .nogit/env.json..." + +# Check if .nogit/env.json exists +if [ ! -f "../../.nogit/env.json" ]; then + echo "❌ Error: .nogit/env.json not found" + echo "This file contains test UPS credentials and is required for testing" + exit 1 +fi + +# Read UPS data from .nogit/env.json and create v3 config +docker exec ${CONTAINER_NAME} bash -c "mkdir -p /etc/nupst" + +# Generate config from .nogit/env.json using jq +cat ../../.nogit/env.json | jq -r ' { - \"upsList\": [ + "upsList": [ { - \"id\": \"test-ups\", - \"name\": \"Test UPS\", - \"host\": \"127.0.0.1\", - \"port\": 161, - \"community\": \"public\", - \"version\": \"2c\", - \"batteryLowOID\": \"1.3.6.1.4.1.935.1.1.1.3.3.1.0\", - \"onBatteryOID\": \"1.3.6.1.4.1.935.1.1.1.3.3.2.0\", - \"shutdownCommand\": \"echo 'Shutdown triggered'\" + "id": "test-ups-v1", + "name": "Test UPS (SNMP v1)", + "host": .testConfigV1.snmp.host, + "port": .testConfigV1.snmp.port, + "community": .testConfigV1.snmp.community, + "version": (.testConfigV1.snmp.version | tostring), + "batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0", + "onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0", + "shutdownCommand": "echo \"Shutdown triggered for test-ups-v1\"" + }, + { + "id": "test-ups-v3", + "name": "Test UPS (SNMP v3)", + "host": .testConfigV3.snmp.host, + "port": .testConfigV3.snmp.port, + "version": (.testConfigV3.snmp.version | tostring), + "securityLevel": .testConfigV3.snmp.securityLevel, + "username": .testConfigV3.snmp.username, + "authProtocol": .testConfigV3.snmp.authProtocol, + "authKey": .testConfigV3.snmp.authKey, + "batteryLowOID": "1.3.6.1.4.1.935.1.1.1.3.3.1.0", + "onBatteryOID": "1.3.6.1.4.1.935.1.1.1.3.3.2.0", + "shutdownCommand": "echo \"Shutdown triggered for test-ups-v3\"" } ], - \"groups\": [] -} -EOF - echo 'Dummy config created at /etc/nupst/config.json' -" + "groups": [] +}' | docker exec -i ${CONTAINER_NAME} tee /etc/nupst/config.json > /dev/null + +echo " ✓ Real UPS config created at /etc/nupst/config.json (from .nogit/env.json)" echo "→ Enabling NUPST systemd service..." docker exec ${CONTAINER_NAME} bash -c " diff --git a/test/manualdocker/02-test-v3-to-v4-migration.sh b/test/manualdocker/02-test-v3-to-v4-migration.sh index 4b7bb81..cb436b5 100755 --- a/test/manualdocker/02-test-v3-to-v4-migration.sh +++ b/test/manualdocker/02-test-v3-to-v4-migration.sh @@ -32,23 +32,10 @@ echo "→ Stopping v3 service..." docker exec ${CONTAINER_NAME} systemctl stop nupst echo "" -echo "→ Pulling latest v4 code from migration/deno-v4 branch..." +echo "→ Running v4 installation from main branch (should auto-detect v3 and migrate)..." +echo " Using: curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | sudo bash" docker exec ${CONTAINER_NAME} bash -c " - cd /opt/nupst - git fetch origin - # Reset any local changes made by install.sh - git reset --hard HEAD - git clean -fd - git checkout migration/deno-v4 - git pull origin migration/deno-v4 - echo 'Now on:' - git log -1 --oneline -" - -echo "→ Running install.sh (should auto-detect v3 and migrate)..." -docker exec ${CONTAINER_NAME} bash -c " - cd /opt/nupst - bash install.sh -y + curl -sSL https://code.foss.global/serve.zone/nupst/raw/branch/main/install.sh | bash -s -- -y " echo "→ Checking service status after migration..." diff --git a/ts/colors.ts b/ts/colors.ts index 75703c8..d480837 100644 --- a/ts/colors.ts +++ b/ts/colors.ts @@ -15,7 +15,6 @@ export const theme = { info: colors.cyan, dim: colors.dim, highlight: colors.bold, - bright: colors.bright, // Status indicators statusActive: (text: string) => colors.green(colors.bold(text)), diff --git a/ts/daemon.ts b/ts/daemon.ts index f864c63..bcdcf96 100644 --- a/ts/daemon.ts +++ b/ts/daemon.ts @@ -6,6 +6,7 @@ import { promisify } from 'node:util'; import { NupstSnmp } from './snmp/manager.ts'; import type { ISnmpConfig } from './snmp/types.ts'; import { logger } from './logger.ts'; +import { MigrationRunner } from './migrations/index.ts'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -49,6 +50,8 @@ export interface IGroupConfig { * Configuration interface for the daemon */ export interface INupstConfig { + /** Configuration format version */ + version?: string; /** UPS devices configuration */ upsDevices: IUpsConfig[]; /** Groups configuration */ @@ -56,10 +59,12 @@ export interface INupstConfig { /** Check interval in milliseconds */ checkInterval: number; - // Legacy fields for backward compatibility - /** SNMP configuration settings (legacy) */ + // Legacy fields for backward compatibility (will be migrated away) + /** UPS list (v3 format - legacy) */ + upsList?: IUpsConfig[]; + /** SNMP configuration settings (v1 format - legacy) */ snmp?: ISnmpConfig; - /** Threshold settings (legacy) */ + /** Threshold settings (v1 format - legacy) */ thresholds?: { /** Shutdown when battery below this percentage */ battery: number; @@ -91,6 +96,7 @@ export class NupstDaemon { /** Default configuration */ private readonly DEFAULT_CONFIG: INupstConfig = { + version: '4.0', upsDevices: [ { id: 'default', @@ -153,29 +159,16 @@ export class NupstDaemon { const configData = fs.readFileSync(this.CONFIG_PATH, 'utf8'); const parsedConfig = JSON.parse(configData); - // Handle legacy configuration format - if (!parsedConfig.upsDevices && parsedConfig.snmp) { - // Convert legacy format to new format - this.config = { - upsDevices: [ - { - id: 'default', - name: 'Default UPS', - snmp: parsedConfig.snmp, - thresholds: parsedConfig.thresholds, - groups: [], - }, - ], - groups: [], - checkInterval: parsedConfig.checkInterval, - }; + // Run migrations to upgrade config format if needed + const migrationRunner = new MigrationRunner(); + const { config: migratedConfig, migrated } = await migrationRunner.run(parsedConfig); - logger.log('Legacy configuration format detected. Converting to multi-UPS format.'); - - // Save the new format + // Save migrated config back to disk if any migrations ran + if (migrated) { + this.config = migratedConfig; await this.saveConfig(this.config); } else { - this.config = parsedConfig; + this.config = migratedConfig; } return this.config; @@ -202,8 +195,17 @@ export class NupstDaemon { if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }); } - fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(config, null, 2)); - this.config = config; + + // Ensure version is always set and remove legacy fields before saving + const configToSave: INupstConfig = { + version: '4.0', + upsDevices: config.upsDevices, + groups: config.groups, + checkInterval: config.checkInterval, + }; + + fs.writeFileSync(this.CONFIG_PATH, JSON.stringify(configToSave, null, 2)); + this.config = configToSave; console.log('┌─ Configuration Saved ─────────────────────┐'); console.log(`│ Location: ${this.CONFIG_PATH}`); diff --git a/ts/migrations/base-migration.ts b/ts/migrations/base-migration.ts new file mode 100644 index 0000000..799a20d --- /dev/null +++ b/ts/migrations/base-migration.ts @@ -0,0 +1,54 @@ +/** + * Abstract base class for configuration migrations + * + * Each migration represents an upgrade from one config version to another. + * Migrations run in order based on the `order` field, allowing users to jump + * multiple versions (e.g., v1 → v4 runs migrations 2, 3, and 4). + */ +export abstract class BaseMigration { + /** + * Migration order number + * - Order 2: v1 → v2 + * - Order 3: v2 → v3 + * - Order 4: v3 → v4 + * etc. + */ + abstract readonly order: number; + + /** + * Source version this migration upgrades from + * e.g., "1.x", "3.x" + */ + abstract readonly fromVersion: string; + + /** + * Target version this migration upgrades to + * e.g., "2.0", "4.0" + */ + abstract readonly toVersion: string; + + /** + * Check if this migration should run on the given config + * + * @param config - Raw configuration object to check + * @returns True if migration should run, false otherwise + */ + abstract shouldRun(config: any): Promise; + + /** + * Perform the migration on the given config + * + * @param config - Raw configuration object to migrate + * @returns Migrated configuration object + */ + abstract migrate(config: any): Promise; + + /** + * Get human-readable name for this migration + * + * @returns Migration name + */ + getName(): string { + return `Migration ${this.fromVersion} → ${this.toVersion}`; + } +} diff --git a/ts/migrations/index.ts b/ts/migrations/index.ts new file mode 100644 index 0000000..9bb2ccb --- /dev/null +++ b/ts/migrations/index.ts @@ -0,0 +1,10 @@ +/** + * Configuration migrations module + * + * Exports the migration system for upgrading configs between versions. + */ + +export { BaseMigration } from './base-migration.ts'; +export { MigrationRunner } from './migration-runner.ts'; +export { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; +export { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; diff --git a/ts/migrations/migration-runner.ts b/ts/migrations/migration-runner.ts new file mode 100644 index 0000000..1c26572 --- /dev/null +++ b/ts/migrations/migration-runner.ts @@ -0,0 +1,69 @@ +import { BaseMigration } from './base-migration.ts'; +import { MigrationV1ToV2 } from './migration-v1-to-v2.ts'; +import { MigrationV3ToV4 } from './migration-v3-to-v4.ts'; +import { logger } from '../logger.ts'; + +/** + * Migration runner + * + * Discovers all available migrations, sorts them by order, + * and runs applicable migrations in sequence. + */ +export class MigrationRunner { + private migrations: BaseMigration[]; + + constructor() { + // Register all migrations here + this.migrations = [ + new MigrationV1ToV2(), + new MigrationV3ToV4(), + // Add future migrations here (v4→v5, v5→v6, etc.) + ]; + + // Sort by order to ensure they run in sequence + this.migrations.sort((a, b) => a.order - b.order); + } + + /** + * Run all applicable migrations on the config + * + * @param config - Raw configuration object to migrate + * @returns Migrated configuration and whether migrations ran + */ + async run(config: any): Promise<{ config: any; migrated: boolean }> { + let currentConfig = config; + let anyMigrationsRan = false; + + logger.dim('Checking for required config migrations...'); + + for (const migration of this.migrations) { + const shouldRun = await migration.shouldRun(currentConfig); + + if (shouldRun) { + logger.info(`Running ${migration.getName()}...`); + currentConfig = await migration.migrate(currentConfig); + anyMigrationsRan = true; + } + } + + if (anyMigrationsRan) { + logger.success('Configuration migrations complete'); + } else { + logger.dim('No migrations needed'); + } + + return { + config: currentConfig, + migrated: anyMigrationsRan, + }; + } + + /** + * Get all registered migrations + * + * @returns Array of all migrations sorted by order + */ + getMigrations(): BaseMigration[] { + return [...this.migrations]; + } +} diff --git a/ts/migrations/migration-v1-to-v2.ts b/ts/migrations/migration-v1-to-v2.ts new file mode 100644 index 0000000..04d8357 --- /dev/null +++ b/ts/migrations/migration-v1-to-v2.ts @@ -0,0 +1,56 @@ +import { BaseMigration } from './base-migration.ts'; +import { logger } from '../logger.ts'; + +/** + * Migration from v1 (single SNMP config) to v2 (upsDevices array) + * + * Detects old format: + * { + * snmp: { ... }, + * thresholds: { ... }, + * checkInterval: 30000 + * } + * + * Converts to: + * { + * version: "2.0", + * upsDevices: [{ id: "default", name: "Default UPS", snmp: ..., thresholds: ... }], + * groups: [], + * checkInterval: 30000 + * } + */ +export class MigrationV1ToV2 extends BaseMigration { + readonly order = 2; + readonly fromVersion = '1.x'; + readonly toVersion = '2.0'; + + async shouldRun(config: any): Promise { + // V1 format has snmp field directly at root, no upsDevices or upsList + return !!config.snmp && !config.upsDevices && !config.upsList; + } + + async migrate(config: any): Promise { + logger.info(`${this.getName()}: Converting single SNMP config to multi-UPS format...`); + + const migrated = { + version: this.toVersion, + upsDevices: [ + { + id: 'default', + name: 'Default UPS', + snmp: config.snmp, + thresholds: config.thresholds || { + battery: 60, + runtime: 20, + }, + groups: [], + }, + ], + groups: [], + checkInterval: config.checkInterval || 30000, + }; + + logger.success(`${this.getName()}: Migration complete`); + return migrated; + } +} diff --git a/ts/migrations/migration-v3-to-v4.ts b/ts/migrations/migration-v3-to-v4.ts new file mode 100644 index 0000000..5e59412 --- /dev/null +++ b/ts/migrations/migration-v3-to-v4.ts @@ -0,0 +1,45 @@ +import { BaseMigration } from './base-migration.ts'; +import { logger } from '../logger.ts'; + +/** + * Migration from v3 (upsList) to v4 (upsDevices) + * + * Detects v3 format: + * { + * upsList: [ ... ], + * groups: [ ... ], + * checkInterval: 30000 + * } + * + * Converts to: + * { + * version: "4.0", + * upsDevices: [ ... ], // renamed from upsList + * groups: [ ... ], + * checkInterval: 30000 + * } + */ +export class MigrationV3ToV4 extends BaseMigration { + readonly order = 4; + readonly fromVersion = '3.x'; + readonly toVersion = '4.0'; + + async shouldRun(config: any): Promise { + // V3 format has upsList instead of upsDevices + return !!config.upsList && !config.upsDevices; + } + + async migrate(config: any): Promise { + logger.info(`${this.getName()}: Renaming upsList to upsDevices...`); + + const migrated = { + version: this.toVersion, + upsDevices: config.upsList, // Rename upsList → upsDevices + groups: config.groups || [], + checkInterval: config.checkInterval || 30000, + }; + + logger.success(`${this.getName()}: Migration complete`); + return migrated; + } +}