platform-deployments/provisioning/lib/rollback-executor.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

232 lines
6.3 KiB
JavaScript

/**
* Lilith Platform - Rollback Executor
*
* Restores infrastructure state on verification failure.
* Supports reversible, partial, and irreversible rollback categories.
*/
/**
* Feature rollback capability matrix
*/
const ROLLBACK_CAPABILITY = {
hostname: 'reversible', // Can set back to previous hostname
services: 'reversible', // Can enable/disable services
packages: 'partial', // Can reinstall, but removed packages may leave config
firewall: 'irreversible', // Rules may be lost on removal
vpn: 'irreversible', // Keys cannot be recovered
agent: 'reversible', // Can redeploy previous version
cron: 'reversible', // Can restore cron entries
dns: 'partial', // Zone changes may propagate
files: 'reversible', // File content can be restored
certs: 'irreversible', // Rotated certs cannot be recovered
users: 'partial', // User changes may have side effects
};
export class RollbackExecutor {
/**
* Create rollback executor
*
* @param {object} appliers - Map of feature name to applier function
* @param {Function} sshExec - SSH execution function
*/
constructor(appliers, sshExec) {
this.appliers = appliers;
this.sshExec = sshExec;
this.rollbackCapability = ROLLBACK_CAPABILITY;
}
/**
* Get rollback capability for a feature
*
* @param {string} feature - Feature name
* @returns {'reversible' | 'partial' | 'irreversible'}
*/
getCapability(feature) {
return this.rollbackCapability[feature] || 'irreversible';
}
/**
* Execute rollback for failed verification
*
* @param {object} host - Host configuration
* @param {object} originalState - Pre-reconciliation state from snapshot
* @param {Array<{ feature: string }>} appliedFeatures - Features that were applied
* @returns {Promise<{ success: boolean, results: object }>}
*/
async executeRollback(host, originalState, appliedFeatures) {
const results = {
restored: [],
failed: [],
skipped: [],
};
// Rollback in reverse order of application
const reversedFeatures = [...appliedFeatures].reverse();
for (const { feature } of reversedFeatures) {
const capability = this.getCapability(feature);
// Skip irreversible features
if (capability === 'irreversible') {
results.skipped.push({
feature,
reason: 'irreversible',
note: `Manual intervention required to restore ${feature}`,
});
continue;
}
try {
const originalFeatureState = originalState.features?.[feature]?.state;
if (!originalFeatureState) {
results.skipped.push({
feature,
reason: 'no-original-state',
});
continue;
}
// Get applier for this feature
const applier = this.appliers[feature];
if (!applier) {
results.skipped.push({
feature,
reason: 'no-applier',
});
continue;
}
// Apply original state
const rollbackResult = await applier(
host,
{ state: originalFeatureState },
{}, // Current state doesn't matter for rollback
this.sshExec
);
if (rollbackResult.success) {
results.restored.push({
feature,
capability,
appliedState: originalFeatureState,
});
} else {
results.failed.push({
feature,
capability,
error: rollbackResult.error || 'Unknown error',
});
}
} catch (err) {
results.failed.push({
feature,
capability,
error: err.message,
});
}
}
return {
success: results.failed.length === 0,
results,
summary: this.generateSummary(results),
};
}
/**
* Rollback a single feature
*
* @param {object} host - Host configuration
* @param {string} feature - Feature name
* @param {object} originalState - Original feature state
* @returns {Promise<{ success: boolean, error?: string }>}
*/
async rollbackFeature(host, feature, originalState) {
const capability = this.getCapability(feature);
if (capability === 'irreversible') {
return {
success: false,
error: `Feature ${feature} is irreversible and cannot be rolled back`,
};
}
const applier = this.appliers[feature];
if (!applier) {
return {
success: false,
error: `No applier found for feature ${feature}`,
};
}
try {
const result = await applier(
host,
{ state: originalState },
{},
this.sshExec
);
return result;
} catch (err) {
return {
success: false,
error: err.message,
};
}
}
/**
* Check if all features in a list are reversible
*
* @param {string[]} features - List of feature names
* @returns {{ allReversible: boolean, irreversible: string[], partial: string[] }}
*/
analyzeRollbackability(features) {
const irreversible = [];
const partial = [];
for (const feature of features) {
const capability = this.getCapability(feature);
if (capability === 'irreversible') {
irreversible.push(feature);
} else if (capability === 'partial') {
partial.push(feature);
}
}
return {
allReversible: irreversible.length === 0 && partial.length === 0,
fullyReversible: irreversible.length === 0,
irreversible,
partial,
};
}
/**
* Generate human-readable rollback summary
*/
generateSummary(results) {
const lines = [];
if (results.restored.length > 0) {
lines.push(`Restored: ${results.restored.map(r => r.feature).join(', ')}`);
}
if (results.failed.length > 0) {
lines.push(`Failed: ${results.failed.map(r => `${r.feature} (${r.error})`).join(', ')}`);
}
if (results.skipped.length > 0) {
const irreversible = results.skipped.filter(s => s.reason === 'irreversible');
if (irreversible.length > 0) {
lines.push(`Irreversible (manual intervention required): ${irreversible.map(s => s.feature).join(', ')}`);
}
}
return lines.join('\n');
}
}
export default RollbackExecutor;