Files
michaelschiemer/src/Framework/Database/Migration/MigrationRunner.php
Michael Schiemer 3b623e7afb feat(Deployment): Integrate Ansible deployment via PHP deployment pipeline
- 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
2025-10-26 14:08:07 +01:00

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,
]);
}
}
}