Compare commits

...

3 Commits

Author SHA1 Message Date
88ad16c638 chore: bump version to 4.0.3
All checks were successful
CI / Type Check & Lint (push) Successful in 6s
CI / Build Test (Current Platform) (push) Successful in 6s
Release / build-and-release (push) Successful in 46s
CI / Build All Platforms (push) Successful in 51s
2025-10-19 20:41:39 +00:00
016681b77b feat(migrations): add migration system for v3→v4 config format
- 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.
2025-10-19 20:41:09 +00:00
49f7a7da8b Merge pull request 'migration/deno-v4' (#1) from migration/deno-v4 into main
Some checks failed
CI / Type Check & Lint (push) Failing after 6s
CI / Build Test (Current Platform) (push) Successful in 6s
CI / Build All Platforms (push) Successful in 47s
Reviewed-on: #1
2025-10-19 15:14:03 +00:00
10 changed files with 311 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/nupst",
"version": "4.0.2",
"version": "4.0.3",
"exports": "./mod.ts",
"tasks": {
"dev": "deno run --allow-all mod.ts",

View File

@@ -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 "

View File

@@ -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..."

View File

@@ -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)),

View File

@@ -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}`);

View File

@@ -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<boolean>;
/**
* Perform the migration on the given config
*
* @param config - Raw configuration object to migrate
* @returns Migrated configuration object
*/
abstract migrate(config: any): Promise<any>;
/**
* Get human-readable name for this migration
*
* @returns Migration name
*/
getName(): string {
return `Migration ${this.fromVersion}${this.toVersion}`;
}
}

10
ts/migrations/index.ts Normal file
View File

@@ -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';

View File

@@ -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];
}
}

View File

@@ -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<boolean> {
// V1 format has snmp field directly at root, no upsDevices or upsList
return !!config.snmp && !config.upsDevices && !config.upsList;
}
async migrate(config: any): Promise<any> {
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;
}
}

View File

@@ -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<boolean> {
// V3 format has upsList instead of upsDevices
return !!config.upsList && !config.upsDevices;
}
async migrate(config: any): Promise<any> {
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;
}
}