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>
232 lines
6.3 KiB
JavaScript
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;
|