- Create AnsibleDeployStage using framework's Process module for secure command execution - Integrate AnsibleDeployStage into DeploymentPipelineCommands for production deployments - Add force_deploy flag support in Ansible playbook to override stale locks - Use PHP deployment module as orchestrator (php console.php deploy:production) - Fix ErrorAggregationInitializer to use Environment class instead of $_ENV superglobal Architecture: - BuildStage → AnsibleDeployStage → HealthCheckStage for production - Process module provides timeout, error handling, and output capture - Ansible playbook supports rollback via rollback-git-based.yml - Zero-downtime deployments with health checks
452 lines
18 KiB
PHP
452 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Database\Migration;
|
|
|
|
use App\Framework\Database\ConnectionInterface;
|
|
use App\Framework\Database\Migration\Services\MigrationDatabaseManager;
|
|
use App\Framework\Database\Migration\Services\MigrationErrorAnalyzer;
|
|
use App\Framework\Database\Migration\Services\MigrationLogger;
|
|
use App\Framework\Database\Migration\Services\MigrationPerformanceTracker;
|
|
use App\Framework\Database\Migration\Services\MigrationValidator;
|
|
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
|
|
use App\Framework\Database\Migration\ValueObjects\MigrationTableConfig;
|
|
use App\Framework\Database\Platform\DatabasePlatform;
|
|
use App\Framework\DateTime\Clock;
|
|
use App\Framework\Exception\Core\DatabaseErrorCode;
|
|
use App\Framework\Exception\ExceptionContext;
|
|
use App\Framework\Exception\FrameworkException;
|
|
use App\Framework\Logging\Logger;
|
|
use App\Framework\Performance\MemoryMonitor;
|
|
use App\Framework\Performance\OperationTracker;
|
|
use App\Framework\Performance\PerformanceReporter;
|
|
use App\Framework\Performance\Repository\PerformanceMetricsRepository;
|
|
use App\Framework\Ulid\UlidGenerator;
|
|
|
|
final readonly class MigrationRunner
|
|
{
|
|
private MigrationDependencyGraph $dependencyGraph;
|
|
|
|
private MigrationDatabaseManager $databaseManager;
|
|
|
|
private MigrationPerformanceTracker $performanceTracker;
|
|
|
|
private MigrationLogger $migrationLogger;
|
|
|
|
private MigrationValidator $validator;
|
|
|
|
private MigrationErrorAnalyzer $errorAnalyzer;
|
|
|
|
public function __construct(
|
|
private ConnectionInterface $connection,
|
|
private DatabasePlatform $platform,
|
|
private Clock $clock,
|
|
private UlidGenerator $ulidGenerator,
|
|
?MigrationTableConfig $tableConfig = null,
|
|
?Logger $logger = null,
|
|
?OperationTracker $operationTracker = null,
|
|
?MemoryMonitor $memoryMonitor = null,
|
|
?PerformanceReporter $performanceReporter = null,
|
|
?MemoryThresholds $memoryThresholds = null,
|
|
?PerformanceMetricsRepository $performanceMetricsRepository = null
|
|
) {
|
|
$this->dependencyGraph = new MigrationDependencyGraph();
|
|
|
|
$effectiveTableConfig = $tableConfig ?? MigrationTableConfig::default();
|
|
|
|
// Initialize service classes
|
|
$this->databaseManager = new MigrationDatabaseManager(
|
|
$this->connection,
|
|
$this->platform,
|
|
$this->clock,
|
|
$effectiveTableConfig
|
|
);
|
|
|
|
$this->performanceTracker = new MigrationPerformanceTracker(
|
|
$operationTracker,
|
|
$memoryMonitor,
|
|
$performanceMetricsRepository,
|
|
$logger,
|
|
$memoryThresholds
|
|
);
|
|
|
|
$this->migrationLogger = new MigrationLogger($logger);
|
|
|
|
$this->validator = new MigrationValidator(
|
|
$this->connection,
|
|
$this->platform
|
|
);
|
|
|
|
$this->errorAnalyzer = new MigrationErrorAnalyzer();
|
|
}
|
|
|
|
/**
|
|
* Run migrations
|
|
*/
|
|
public function migrate(MigrationCollection $migrations, bool $skipPreflightChecks = false): array
|
|
{
|
|
$this->databaseManager->ensureMigrationsTable();
|
|
|
|
$executedMigrations = [];
|
|
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
|
|
|
|
// Pre-flight checks
|
|
if (! $skipPreflightChecks) {
|
|
$this->runPreFlightChecks($migrations, $appliedVersions);
|
|
}
|
|
|
|
// Filter and order migrations
|
|
$pendingMigrations = $migrations->filterByNotApplied($appliedVersions);
|
|
if ($pendingMigrations->isEmpty()) {
|
|
$this->migrationLogger->logMigration('none', 'No pending migrations', 'Skipped');
|
|
|
|
return [];
|
|
}
|
|
|
|
$this->dependencyGraph->buildGraph($pendingMigrations);
|
|
$orderedMigrations = $this->dependencyGraph->getExecutionOrder();
|
|
$totalMigrations = $orderedMigrations->count();
|
|
|
|
// Start batch tracking
|
|
$batchOperationId = 'migration_batch_' . $this->ulidGenerator->generate();
|
|
$this->performanceTracker->startBatchOperation($batchOperationId, $totalMigrations);
|
|
|
|
$currentPosition = 0;
|
|
foreach ($orderedMigrations as $migration) {
|
|
$version = $migration->getVersion()->toString();
|
|
$currentPosition++;
|
|
|
|
if ($appliedVersions->contains($migration->getVersion())) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Start individual migration tracking
|
|
$migrationOperationId = "migration_{$version}";
|
|
$this->performanceTracker->startMigrationOperation(
|
|
$migrationOperationId,
|
|
$version,
|
|
$migration->getDescription(),
|
|
$currentPosition,
|
|
$totalMigrations,
|
|
$batchOperationId
|
|
);
|
|
|
|
// Check memory thresholds before migration
|
|
$this->performanceTracker->checkMemoryThresholds($version, $currentPosition - 1, $totalMigrations);
|
|
|
|
// Execute migration in transaction
|
|
$this->connection->beginTransaction();
|
|
|
|
try {
|
|
$migration->up($this->connection);
|
|
$this->databaseManager->recordMigrationExecution($migration, $version);
|
|
|
|
if ($this->connection->inTransaction()) {
|
|
$this->connection->commit();
|
|
}
|
|
|
|
// Update operation with successful execution
|
|
$this->performanceTracker->updateBatchOperation($batchOperationId, [
|
|
'items_processed' => $currentPosition,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
if ($this->connection->inTransaction()) {
|
|
$this->connection->rollback();
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$executedMigrations[] = $version;
|
|
|
|
// Complete individual migration tracking
|
|
$migrationSnapshot = $this->performanceTracker->completeMigrationOperation(
|
|
$migrationOperationId,
|
|
$version,
|
|
$migration->getDescription(),
|
|
$currentPosition,
|
|
$totalMigrations,
|
|
$batchOperationId
|
|
);
|
|
|
|
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Migrated');
|
|
$this->migrationLogger->logMigrationProgress($version, $currentPosition, $totalMigrations, 'completed', [
|
|
'execution_time' => $migrationSnapshot?->duration?->toSeconds(),
|
|
'memory_used' => $migrationSnapshot?->memoryDelta?->toHumanReadable(),
|
|
]);
|
|
|
|
// Post-migration memory check
|
|
$this->performanceTracker->logPostMigrationMemory($version, $migrationSnapshot);
|
|
|
|
} catch (\Throwable $e) {
|
|
// Track failed individual migration
|
|
$failedSnapshot = $this->performanceTracker->failMigrationOperation(
|
|
$migrationOperationId,
|
|
$version,
|
|
$migration->getDescription(),
|
|
$currentPosition,
|
|
$totalMigrations,
|
|
$batchOperationId,
|
|
$e
|
|
);
|
|
|
|
// Track failed batch
|
|
$this->performanceTracker->failBatchOperation($batchOperationId, $e);
|
|
|
|
// Enhanced error reporting with recovery hints
|
|
$recoveryHints = $this->errorAnalyzer->generateMigrationRecoveryHints($migration, $e, $executedMigrations);
|
|
$migrationContext = $this->errorAnalyzer->analyzeMigrationContext($migration, $version, $e);
|
|
|
|
throw FrameworkException::create(
|
|
DatabaseErrorCode::MIGRATION_FAILED,
|
|
"Migration {$version} failed: {$e->getMessage()}"
|
|
)->withContext(
|
|
ExceptionContext::forOperation('migration.execute', 'MigrationRunner')
|
|
->withData([
|
|
'migration_version' => $version,
|
|
'migration_description' => $migration->getDescription(),
|
|
'executed_migrations' => $executedMigrations,
|
|
'total_migrations' => $totalMigrations,
|
|
'current_position' => $currentPosition,
|
|
])
|
|
->withDebug($migrationContext)
|
|
->withMetadata([
|
|
'recovery_hints' => $recoveryHints,
|
|
'can_retry' => $this->errorAnalyzer->canRetryMigration($e),
|
|
'rollback_recommended' => $this->errorAnalyzer->isRollbackRecommended($e, $executedMigrations),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
// Complete batch tracking
|
|
$batchSnapshot = $this->performanceTracker->completeBatchOperation($batchOperationId);
|
|
$this->migrationLogger->logMigrationBatchSummary($executedMigrations, $totalMigrations, $batchSnapshot);
|
|
|
|
return $executedMigrations;
|
|
}
|
|
|
|
/**
|
|
* Rollback migrations (only SafelyReversible migrations)
|
|
*
|
|
* This method will ONLY rollback migrations that implement the SafelyReversible interface,
|
|
* ensuring no data loss occurs during rollback operations.
|
|
*
|
|
* @param MigrationCollection $migrations Available migrations
|
|
* @param int $steps Number of migrations to rollback
|
|
* @return array<Migration> Successfully rolled back migrations
|
|
* @throws FrameworkException If attempting to rollback unsafe migration
|
|
*/
|
|
public function rollback(MigrationCollection $migrations, int $steps = 1): array
|
|
{
|
|
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
|
|
|
|
if ($appliedVersions->isEmpty()) {
|
|
$this->migrationLogger->logMigration('none', 'No migrations to rollback', 'Skipped');
|
|
|
|
return [];
|
|
}
|
|
|
|
$versionsToRollback = array_slice(array_reverse($appliedVersions->toArray()), 0, $steps);
|
|
$rolledBackMigrations = [];
|
|
$totalRollbacks = count($versionsToRollback);
|
|
|
|
// Start rollback batch tracking
|
|
$rollbackBatchId = 'rollback_batch_' . $this->ulidGenerator->generate();
|
|
$this->performanceTracker->startBatchOperation($rollbackBatchId, $totalRollbacks);
|
|
|
|
$currentPosition = 0;
|
|
foreach ($versionsToRollback as $version) {
|
|
$currentPosition++;
|
|
$migration = $migrations->findByVersion(MigrationVersion::fromTimestamp($version));
|
|
|
|
if (! $migration) {
|
|
$this->migrationLogger->logRollbackSkipped($version, ['reason' => 'Migration class not found']);
|
|
|
|
continue;
|
|
}
|
|
|
|
// CRITICAL SAFETY CHECK: Ensure migration supports safe rollback
|
|
if (! $migration instanceof SafelyReversible) {
|
|
throw FrameworkException::create(
|
|
DatabaseErrorCode::MIGRATION_NOT_REVERSIBLE,
|
|
"Migration {$version} does not support safe rollback"
|
|
)->withContext(
|
|
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
|
->withData([
|
|
'migration_version' => $version,
|
|
'migration_class' => get_class($migration),
|
|
'migration_description' => $migration->getDescription(),
|
|
'requested_rollback_steps' => $steps,
|
|
'current_position' => $currentPosition,
|
|
])
|
|
->withMetadata([
|
|
'reason' => 'Migration does not implement SafelyReversible interface',
|
|
'recommendation' => 'Create a new forward migration to undo the changes instead of rolling back',
|
|
'safe_rollback_guide' => 'See SafelyReversible interface documentation for guidelines',
|
|
])
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Validate rollback safety
|
|
$this->validator->validateRollbackSafety($migration, $version);
|
|
|
|
// Real-time rollback progress tracking
|
|
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'starting');
|
|
|
|
// Start individual rollback tracking
|
|
$rollbackOperationId = "rollback_{$version}";
|
|
$this->performanceTracker->startRollbackOperation(
|
|
$rollbackOperationId,
|
|
$version,
|
|
$migration->getDescription(),
|
|
$currentPosition,
|
|
$totalRollbacks,
|
|
$rollbackBatchId,
|
|
$steps
|
|
);
|
|
|
|
// Execute rollback in transaction
|
|
$this->connection->beginTransaction();
|
|
|
|
try {
|
|
$migration->down($this->connection);
|
|
$this->databaseManager->recordMigrationRollback($version);
|
|
|
|
if ($this->connection->inTransaction()) {
|
|
$this->connection->commit();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
if ($this->connection->inTransaction()) {
|
|
$this->connection->rollback();
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
|
|
$rolledBackMigrations[] = $migration;
|
|
|
|
// Complete individual rollback tracking
|
|
$rollbackSnapshot = $this->performanceTracker->completeRollbackOperation(
|
|
$rollbackOperationId,
|
|
$version,
|
|
$migration->getDescription(),
|
|
$currentPosition,
|
|
$totalRollbacks,
|
|
$rollbackBatchId,
|
|
$steps
|
|
);
|
|
|
|
$this->migrationLogger->logMigration($version, $migration->getDescription(), 'Rolled back');
|
|
$this->migrationLogger->logRollbackProgress($version, $currentPosition, $totalRollbacks, 'completed', [
|
|
'execution_time' => $rollbackSnapshot?->duration?->toSeconds(),
|
|
'memory_used' => $rollbackSnapshot?->memoryDelta?->toHumanReadable(),
|
|
]);
|
|
|
|
// Post-rollback memory check
|
|
$this->performanceTracker->logPostRollbackMemory($version, $rollbackSnapshot);
|
|
|
|
} catch (\Throwable $e) {
|
|
// Generate rollback recovery hints
|
|
$remainingRollbacks = array_slice($versionsToRollback, $currentPosition);
|
|
$recoveryHints = $this->errorAnalyzer->generateRollbackRecoveryHints($migration, $e, $remainingRollbacks);
|
|
|
|
throw FrameworkException::create(
|
|
DatabaseErrorCode::MIGRATION_ROLLBACK_FAILED,
|
|
"Rollback failed for migration {$version}: {$e->getMessage()}"
|
|
)->withContext(
|
|
ExceptionContext::forOperation('migration.rollback', 'MigrationRunner')
|
|
->withData([
|
|
'migration_version' => $version,
|
|
'rollback_steps' => $steps,
|
|
'completed_rollbacks' => count($rolledBackMigrations),
|
|
'failed_migration' => $version,
|
|
])
|
|
->withMetadata([
|
|
'recovery_hints' => $recoveryHints,
|
|
'can_continue' => $this->errorAnalyzer->canContinueRollback($remainingRollbacks),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
// Complete rollback batch tracking
|
|
$rollbackSnapshot = $this->performanceTracker->completeBatchOperation($rollbackBatchId);
|
|
$this->migrationLogger->logRollbackBatchSummary($rolledBackMigrations, $totalRollbacks, $rollbackSnapshot);
|
|
|
|
return $rolledBackMigrations;
|
|
}
|
|
|
|
/**
|
|
* Get applied migration versions
|
|
*/
|
|
public function getAppliedVersions(): array
|
|
{
|
|
return $this->databaseManager->getAppliedVersions();
|
|
}
|
|
|
|
/**
|
|
* Get status information for all migrations
|
|
*
|
|
* @return array<MigrationStatus>
|
|
*/
|
|
public function getStatus(MigrationCollection $migrations): array
|
|
{
|
|
$appliedVersions = MigrationVersionCollection::fromStrings($this->databaseManager->getAppliedVersions());
|
|
$statuses = [];
|
|
|
|
foreach ($migrations as $migration) {
|
|
$version = $migration->getVersion();
|
|
$applied = $appliedVersions->contains($version);
|
|
|
|
$statuses[] = new MigrationStatus(
|
|
version: $version,
|
|
description: $migration->getDescription(),
|
|
applied: $applied
|
|
);
|
|
}
|
|
|
|
return $statuses;
|
|
}
|
|
|
|
private function runPreFlightChecks(MigrationCollection $migrations, MigrationVersionCollection $appliedVersions): void
|
|
{
|
|
$results = $this->validator->runPreFlightChecks($migrations, $appliedVersions);
|
|
$dependencyResults = $this->validator->validateMigrationDependencies($migrations, $appliedVersions);
|
|
|
|
// Extract critical issues and warnings
|
|
$criticalIssues = [];
|
|
$warnings = [];
|
|
|
|
foreach ($results as $check => $result) {
|
|
if ($result['status'] === 'fail' && ($result['severity'] ?? 'info') === 'critical') {
|
|
$criticalIssues[] = $check . ': ' . $result['message'];
|
|
} elseif ($result['status'] === 'warning' || ($result['severity'] ?? 'info') === 'warning') {
|
|
$warnings[] = $check . ': ' . $result['message'];
|
|
}
|
|
}
|
|
|
|
$this->migrationLogger->logPreFlightResults($results, $criticalIssues, $warnings);
|
|
$this->migrationLogger->logDependencyValidationIssues(
|
|
$dependencyResults['orphaned_dependencies'],
|
|
$dependencyResults['partial_chains'],
|
|
$dependencyResults['ordering_issues']
|
|
);
|
|
|
|
// Throw exception if critical issues found
|
|
if (! empty($criticalIssues)) {
|
|
throw FrameworkException::create(
|
|
DatabaseErrorCode::MIGRATION_PREFLIGHT_FAILED,
|
|
'Pre-flight checks failed with critical issues'
|
|
)->withData([
|
|
'critical_issues' => $criticalIssues,
|
|
'warnings' => $warnings,
|
|
'full_results' => $results,
|
|
]);
|
|
}
|
|
}
|
|
}
|