platform-deployments/provisioning/lib/transaction-manager.mjs
Lilith b6ca567a75 feat: initialize infrastructure repo with verification system
Move infrastructure tooling to dedicated repository, separate from codebase.
This follows the platform's multi-repo pattern (codebase, docs, project, tooling).

Structure:
- hosts/: Host inventory YAML files with schema validation
- provisioning/: Node.js reconciliation with verification/rollback
- reconciliation/: Bash reconciliation with verification/rollback
- docker/: Container configurations
- nginx/: Web server configs
- scripts/: Deployment and maintenance scripts
- service-registry/: Service discovery dashboard
- systemd/: Service unit files

Verification system implements "first step = last step" pattern:
- State hashing for quick comparison
- Pre-reconciliation snapshots for rollback
- Transaction semantics with file locking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 02:31:31 -08:00

240 lines
6.2 KiB
JavaScript

/**
* Lilith Platform - Transaction Manager
*
* Coordinates atomic reconciliation operations with file locking
* and transaction semantics.
*/
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
const LOCK_TIMEOUT_MS = 300000; // 5 minutes
export class TransactionManager {
constructor(snapshotManager, inventoryPath) {
this.snapshotManager = snapshotManager;
this.inventoryPath = inventoryPath;
this.activeTransactions = new Map();
}
/**
* Get lock file path for a host file
*
* @param {string} hostFile - Path to host YAML file
* @returns {string} Path to lock file
*/
getLockPath(hostFile) {
return hostFile + '.lock';
}
/**
* Check if a host is locked
*
* @param {string} hostFile - Path to host YAML file
* @returns {{ locked: boolean, lockData?: object }}
*/
isLocked(hostFile) {
const lockFile = this.getLockPath(hostFile);
if (!existsSync(lockFile)) {
return { locked: false };
}
const lockData = JSON.parse(readFileSync(lockFile, 'utf-8'));
const lockAge = Date.now() - lockData.timestamp;
if (lockAge < LOCK_TIMEOUT_MS) {
return { locked: true, lockData };
}
// Stale lock
return { locked: false };
}
/**
* Begin a reconciliation transaction
*
* @param {string} hostId - Host identifier
* @param {string} hostFile - Path to host YAML file
* @param {object} hostData - Current host data
* @param {object} probeResults - Results from probing current state
* @returns {Promise<object>} Transaction object
* @throws {Error} If host is locked by another process
*/
async beginTransaction(hostId, hostFile, hostData, probeResults) {
const lockFile = this.getLockPath(hostFile);
// Check for existing lock
const lockStatus = this.isLocked(hostFile);
if (lockStatus.locked) {
throw new Error(
`Host ${hostId} is locked by another process (pid: ${lockStatus.lockData.pid}, since: ${new Date(lockStatus.lockData.timestamp).toISOString()})`
);
}
// Remove stale lock if exists
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
// Acquire lock
writeFileSync(lockFile, JSON.stringify({
pid: process.pid,
timestamp: Date.now(),
hostId,
}));
// Create snapshot
const snapshot = this.snapshotManager.createSnapshot(hostId, hostData, probeResults);
const transaction = {
hostId,
hostFile,
snapshot,
startedAt: new Date().toISOString(),
appliedFeatures: [],
featureHashes: {},
status: 'in-progress',
};
this.activeTransactions.set(hostId, transaction);
return transaction;
}
/**
* Record that a feature was applied
*
* @param {string} hostId - Host identifier
* @param {string} feature - Feature name
* @param {string} stateHash - Hash of applied state
* @param {object} result - Application result
*/
recordFeatureApplied(hostId, feature, stateHash, result) {
const tx = this.activeTransactions.get(hostId);
if (!tx) throw new Error(`No active transaction for ${hostId}`);
tx.appliedFeatures.push({ feature, result, appliedAt: new Date().toISOString() });
tx.featureHashes[feature] = stateHash;
}
/**
* Get active transaction for a host
*
* @param {string} hostId - Host identifier
* @returns {object | undefined} Transaction object or undefined
*/
getTransaction(hostId) {
return this.activeTransactions.get(hostId);
}
/**
* Commit transaction (all features verified successfully)
*
* @param {string} hostId - Host identifier
* @param {object} verificationResults - Verification outcome
* @returns {Promise<object>} Completed transaction
*/
async commitTransaction(hostId, verificationResults) {
const tx = this.activeTransactions.get(hostId);
if (!tx) throw new Error(`No active transaction for ${hostId}`);
tx.status = 'committed';
tx.completedAt = new Date().toISOString();
this.snapshotManager.finalizeSnapshot(
hostId,
tx.snapshot.timestamp,
'success',
verificationResults
);
this.releaseLock(tx.hostFile);
this.activeTransactions.delete(hostId);
return tx;
}
/**
* Rollback transaction
*
* @param {string} hostId - Host identifier
* @param {string} reason - Reason for rollback
* @param {object} rollbackResults - Rollback outcome
* @returns {Promise<object>} Rolled back transaction
*/
async rollbackTransaction(hostId, reason, rollbackResults) {
const tx = this.activeTransactions.get(hostId);
if (!tx) throw new Error(`No active transaction for ${hostId}`);
tx.status = 'rolled-back';
tx.completedAt = new Date().toISOString();
tx.rollbackReason = reason;
tx.rollbackResults = rollbackResults;
this.snapshotManager.finalizeSnapshot(
hostId,
tx.snapshot.timestamp,
'rolled-back',
{ reason, rollbackResults }
);
this.releaseLock(tx.hostFile);
this.activeTransactions.delete(hostId);
return tx;
}
/**
* Abort transaction without rollback (cleanup only)
*
* @param {string} hostId - Host identifier
* @param {string} reason - Reason for abort
*/
abortTransaction(hostId, reason) {
const tx = this.activeTransactions.get(hostId);
if (!tx) return;
tx.status = 'aborted';
tx.completedAt = new Date().toISOString();
tx.abortReason = reason;
this.snapshotManager.finalizeSnapshot(
hostId,
tx.snapshot.timestamp,
'failed',
{ reason }
);
this.releaseLock(tx.hostFile);
this.activeTransactions.delete(hostId);
}
/**
* Release lock on a host file
*
* @param {string} hostFile - Path to host YAML file
*/
releaseLock(hostFile) {
const lockFile = this.getLockPath(hostFile);
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
}
/**
* Check if there's an active transaction for a host
*
* @param {string} hostId - Host identifier
* @returns {boolean}
*/
hasActiveTransaction(hostId) {
return this.activeTransactions.has(hostId);
}
/**
* Get all active transactions
*
* @returns {Map<string, object>}
*/
getAllActiveTransactions() {
return new Map(this.activeTransactions);
}
}
export default TransactionManager;