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>
240 lines
6.2 KiB
JavaScript
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;
|