Task: Implement database migration automation
Description
Implement a comprehensive database migration automation system for the Coolify Enterprise platform that ensures safe, reliable, and auditable database schema changes across development, staging, and production environments. This system provides automated migration validation, pre-flight checks, backup creation, automatic execution with rollback capability, and comprehensive audit logging for all database schema changes.
Modern multi-environment deployments require robust migration management to prevent downtime, data loss, and deployment failures. Manual migration execution is error-prone and lacks safety mechanisms. This task creates an intelligent migration orchestration system that validates migrations before execution, creates automatic backups, handles rollbacks on failures, and provides detailed audit trails for compliance and debugging.
Core Capabilities:
Pre-Migration Validation : Syntax checking, dependency analysis, destructive change detection
Automated Backup System : Database snapshots before migration execution with point-in-time recovery
Intelligent Execution : Batched migrations with transaction support and progress tracking
Automatic Rollback : Failure detection with automatic state restoration
Audit Logging : Complete history of all migrations with user attribution and timestamps
Multi-Environment Support : Environment-specific migration strategies (dev, staging, production)
Zero-Downtime Migrations : Support for online schema changes with minimal locking
Integration Points:
CI/CD Pipeline : Automated migration execution during deployment workflows (Task 89)
Monitoring Dashboards : Migration status and health metrics display (Task 91)
Alert System : Notifications for migration failures and warnings
Backup System : Integration with PostgreSQL backup tools and S3 storage
Organization Context : Organization-scoped migrations for multi-tenant architecture
Why This Task Is Critical:
Database migrations are the riskiest part of any deployment. A failed migration can cause complete application outages, data corruption, or irreversible data loss. Manual migration execution lacks safety checks, audit trails, and rollback capabilities. This automation system transforms migrations from a high-risk manual process into a reliable, auditable, automated workflow with comprehensive safety mechanisms. It's essential for production-grade enterprise deployments where uptime, data integrity, and compliance are non-negotiable.
Real-World Problem Solved:
Problem : Developer runs migrations manually, forgets to backup, migration fails halfway, database is in inconsistent state, no easy rollback
Solution : System automatically backs up before migration, validates syntax, detects failures, rolls back automatically, logs everything for audit trail
Acceptance Criteria
Migration validation system implemented with syntax checking and dependency analysis
Pre-migration checks include: syntax validation, dependency ordering, destructive change detection
Automatic database backup before migration execution (PostgreSQL pg_dump)
Backup storage in S3 with versioning and retention policies
Migration execution with transaction support and progress tracking
Automatic rollback on failures with state restoration
Manual rollback command for reverting successful migrations
Audit logging for all migration events (executed, failed, rolled back)
Migration status tracking (pending, running, completed, failed, rolled back)
Environment-specific migration strategies (skip confirmation in dev, require approval in prod)
Dry-run mode for testing migrations without execution
Support for data migrations with validation hooks
Migration locking to prevent concurrent execution
Email/Slack notifications for migration events
Artisan commands for all migration operations
Web UI for migration management and history
Comprehensive test coverage (unit, integration, feature tests)
Documentation for migration best practices and troubleshooting
Technical Details
File Paths
Service Layer:
/home/topgun/topgun/app/Services/Enterprise/MigrationAutomationService.php (core service)
/home/topgun/topgun/app/Contracts/MigrationAutomationServiceInterface.php (interface)
/home/topgun/topgun/app/Services/Enterprise/DatabaseBackupService.php (backup service)
/home/topgun/topgun/app/Contracts/DatabaseBackupServiceInterface.php (backup interface)
Artisan Commands:
/home/topgun/topgun/app/Console/Commands/MigrateWithBackup.php (automated migration)
/home/topgun/topgun/app/Console/Commands/ValidateMigrations.php (validation)
/home/topgun/topgun/app/Console/Commands/RollbackMigration.php (rollback)
/home/topgun/topgun/app/Console/Commands/MigrationStatus.php (status)
Models:
/home/topgun/topgun/app/Models/MigrationLog.php (migration audit log)
/home/topgun/topgun/app/Models/DatabaseBackup.php (backup tracking)
Controllers:
/home/topgun/topgun/app/Http/Controllers/Enterprise/MigrationController.php (web UI)
Vue Components:
/home/topgun/topgun/resources/js/Components/Enterprise/Migration/MigrationManager.vue (UI)
/home/topgun/topgun/resources/js/Components/Enterprise/Migration/MigrationHistory.vue (history)
Configuration:
/home/topgun/topgun/config/migration-automation.php (configuration)
Jobs:
/home/topgun/topgun/app/Jobs/ExecuteMigrationJob.php (async migration execution)
/home/topgun/topgun/app/Jobs/BackupDatabaseJob.php (async backup)
Database Schema
Migration Logs Table:
<?php
use Illuminate \Database \Migrations \Migration ;
use Illuminate \Database \Schema \Blueprint ;
use Illuminate \Support \Facades \Schema ;
return new class extends Migration
{
public function up (): void
{
Schema::create ('migration_logs ' , function (Blueprint $ table ) {
$ table ->id ();
$ table ->string ('migration_name ' );
$ table ->integer ('batch ' )->nullable ();
$ table ->enum ('status ' , ['pending ' , 'running ' , 'completed ' , 'failed ' , 'rolled_back ' ])->default ('pending ' );
$ table ->enum ('environment ' , ['development ' , 'staging ' , 'production ' ]);
$ table ->foreignId ('executed_by ' )->nullable ()->constrained ('users ' )->nullOnDelete ();
$ table ->timestamp ('started_at ' )->nullable ();
$ table ->timestamp ('completed_at ' )->nullable ();
$ table ->integer ('duration_seconds ' )->nullable ();
$ table ->text ('output ' )->nullable ();
$ table ->text ('error_message ' )->nullable ();
$ table ->foreignId ('backup_id ' )->nullable ()->constrained ('database_backups ' )->nullOnDelete ();
$ table ->json ('metadata ' )->nullable (); // migration file hash, size, etc.
$ table ->timestamps ();
$ table ->index (['migration_name ' , 'status ' ]);
$ table ->index (['status ' , 'environment ' ]);
$ table ->index ('created_at ' );
});
}
public function down (): void
{
Schema::dropIfExists ('migration_logs ' );
}
};
Database Backups Table:
<?php
use Illuminate \Database \Migrations \Migration ;
use Illuminate \Database \Schema \Blueprint ;
use Illuminate \Support \Facades \Schema ;
return new class extends Migration
{
public function up (): void
{
Schema::create ('database_backups ' , function (Blueprint $ table ) {
$ table ->id ();
$ table ->uuid ('uuid ' )->unique ();
$ table ->string ('database_name ' );
$ table ->string ('backup_type ' )->default ('pre-migration ' ); // pre-migration, scheduled, manual
$ table ->bigInteger ('file_size ' )->nullable (); // bytes
$ table ->string ('storage_path ' ); // S3 path
$ table ->string ('local_path ' )->nullable (); // temporary local path
$ table ->string ('compression ' )->default ('gzip ' ); // gzip, none
$ table ->string ('checksum ' ); // SHA256 checksum
$ table ->enum ('status ' , ['pending ' , 'in_progress ' , 'completed ' , 'failed ' ])->default ('pending ' );
$ table ->timestamp ('started_at ' )->nullable ();
$ table ->timestamp ('completed_at ' )->nullable ();
$ table ->integer ('duration_seconds ' )->nullable ();
$ table ->text ('error_message ' )->nullable ();
$ table ->timestamp ('expires_at ' )->nullable (); // retention policy
$ table ->foreignId ('created_by ' )->nullable ()->constrained ('users ' )->nullOnDelete ();
$ table ->json ('metadata ' )->nullable (); // PostgreSQL version, table count, etc.
$ table ->timestamps ();
$ table ->index (['database_name ' , 'status ' ]);
$ table ->index ('created_at ' );
$ table ->index ('expires_at ' );
});
}
public function down (): void
{
Schema::dropIfExists ('database_backups ' );
}
};
Service Interface
File: app/Contracts/MigrationAutomationServiceInterface.php
<?php
namespace App \Contracts ;
use App \Models \MigrationLog ;
use Illuminate \Support \Collection ;
interface MigrationAutomationServiceInterface
{
/**
* Validate pending migrations before execution
*
* @return array Validation results with errors/warnings
*/
public function validatePendingMigrations (): array ;
/**
* Execute pending migrations with automatic backup
*
* @param bool $force Skip confirmation prompts
* @param bool $dryRun Simulate without executing
* @return Collection<MigrationLog>
*/
public function executeMigrations (bool $ force = false , bool $ dryRun = false ): Collection ;
/**
* Rollback last migration batch
*
* @param int|null $steps Number of steps to rollback
* @param bool $force Skip confirmation prompts
* @return bool
*/
public function rollbackMigrations (?int $ steps = null , bool $ force = false ): bool ;
/**
* Get migration status for all migrations
*
* @return Collection Status information
*/
public function getMigrationStatus (): Collection ;
/**
* Check if there are pending migrations
*
* @return bool
*/
public function hasPendingMigrations (): bool ;
/**
* Get migration history with logs
*
* @param int $limit Number of records
* @return Collection<MigrationLog>
*/
public function getMigrationHistory (int $ limit = 50 ): Collection ;
/**
* Create backup before migration
*
* @return \App\Models\DatabaseBackup
*/
public function createPreMigrationBackup (): \App \Models \DatabaseBackup ;
/**
* Restore database from backup
*
* @param int $backupId
* @return bool
*/
public function restoreFromBackup (int $ backupId ): bool ;
/**
* Detect destructive migrations (DROP, TRUNCATE, etc.)
*
* @param string $migrationPath
* @return array Destructive operations found
*/
public function detectDestructiveChanges (string $ migrationPath ): array ;
}
Backup Service Interface:
<?php
namespace App \Contracts ;
use App \Models \DatabaseBackup ;
interface DatabaseBackupServiceInterface
{
/**
* Create database backup using pg_dump
*
* @param string $type Backup type (pre-migration, scheduled, manual)
* @return DatabaseBackup
*/
public function createBackup (string $ type = 'manual ' ): DatabaseBackup ;
/**
* Restore database from backup
*
* @param DatabaseBackup $backup
* @return bool
*/
public function restoreBackup (DatabaseBackup $ backup ): bool ;
/**
* Delete old backups based on retention policy
*
* @return int Number of backups deleted
*/
public function cleanupExpiredBackups (): int ;
/**
* Verify backup integrity
*
* @param DatabaseBackup $backup
* @return bool
*/
public function verifyBackup (DatabaseBackup $ backup ): bool ;
/**
* Get backup size estimate
*
* @return int Bytes
*/
public function estimateBackupSize (): int ;
}
Service Implementation
File: app/Services/Enterprise/MigrationAutomationService.php
<?php
namespace App \Services \Enterprise ;
use App \Contracts \MigrationAutomationServiceInterface ;
use App \Contracts \DatabaseBackupServiceInterface ;
use App \Models \MigrationLog ;
use App \Models \DatabaseBackup ;
use App \Notifications \MigrationFailedNotification ;
use App \Notifications \MigrationSuccessNotification ;
use Illuminate \Support \Facades \DB ;
use Illuminate \Support \Facades \Log ;
use Illuminate \Support \Facades \Artisan ;
use Illuminate \Support \Facades \File ;
use Illuminate \Support \Facades \Cache ;
use Illuminate \Support \Collection ;
use Symfony \Component \Process \Process ;
class MigrationAutomationService implements MigrationAutomationServiceInterface
{
private const MIGRATION_LOCK_KEY = 'migration:executing ' ;
private const LOCK_TIMEOUT = 3600 ; // 1 hour
public function __construct (
private DatabaseBackupServiceInterface $ backupService
) {}
/**
* Validate pending migrations
*/
public function validatePendingMigrations (): array
{
$ validationResults = [
'valid ' => true ,
'errors ' => [],
'warnings ' => [],
'pending_count ' => 0 ,
];
$ pendingMigrations = $ this ->getPendingMigrationFiles ();
$ validationResults ['pending_count ' ] = count ($ pendingMigrations );
if (empty ($ pendingMigrations )) {
return $ validationResults ;
}
foreach ($ pendingMigrations as $ migration ) {
// Check syntax
$ syntaxCheck = $ this ->validateMigrationSyntax ($ migration );
if (!$ syntaxCheck ['valid ' ]) {
$ validationResults ['valid ' ] = false ;
$ validationResults ['errors ' ][] = "Syntax error in {$ migration }: {$ syntaxCheck ['error ' ]}" ;
}
// Check for destructive operations
$ destructive = $ this ->detectDestructiveChanges ($ migration );
if (!empty ($ destructive )) {
$ validationResults ['warnings ' ][] = "Destructive operations in {$ migration }: " . implode (', ' , $ destructive );
}
// Check dependencies
$ dependencyCheck = $ this ->checkMigrationDependencies ($ migration );
if (!$ dependencyCheck ['valid ' ]) {
$ validationResults ['valid ' ] = false ;
$ validationResults ['errors ' ][] = "Dependency error in {$ migration }: {$ dependencyCheck ['error ' ]}" ;
}
}
return $ validationResults ;
}
/**
* Execute pending migrations with backup
*/
public function executeMigrations (bool $ force = false , bool $ dryRun = false ): Collection
{
// Acquire migration lock
if (!$ this ->acquireMigrationLock ()) {
throw new \RuntimeException ('Migration is already in progress ' );
}
try {
Log::info ('Starting migration execution ' , [
'force ' => $ force ,
'dry_run ' => $ dryRun ,
'environment ' => app ()->environment (),
]);
// Validate migrations
$ validation = $ this ->validatePendingMigrations ();
if (!$ validation ['valid ' ] && !$ force ) {
throw new \RuntimeException ('Migration validation failed: ' . implode ('; ' , $ validation ['errors ' ]));
}
$ migrations = collect ();
if ($ dryRun ) {
Log::info ('Dry run mode - no actual migration execution ' );
return $ this ->simulateMigrations ();
}
// Create backup
$ backup = $ this ->createPreMigrationBackup ();
Log::info ('Pre-migration backup created ' , ['backup_id ' => $ backup ->id ]);
// Get pending migrations
$ pendingMigrations = $ this ->getPendingMigrationFiles ();
foreach ($ pendingMigrations as $ migrationFile ) {
$ migrationLog = $ this ->executeSingleMigration ($ migrationFile , $ backup );
$ migrations ->push ($ migrationLog );
if ($ migrationLog ->status === 'failed ' ) {
Log::error ('Migration failed, initiating rollback ' , [
'migration ' => $ migrationFile ,
'error ' => $ migrationLog ->error_message ,
]);
$ this ->handleMigrationFailure ($ migrationLog , $ backup );
break ;
}
}
$ successCount = $ migrations ->where ('status ' , 'completed ' )->count ();
$ failCount = $ migrations ->where ('status ' , 'failed ' )->count ();
Log::info ('Migration execution completed ' , [
'total ' => $ migrations ->count (),
'success ' => $ successCount ,
'failed ' => $ failCount ,
]);
// Send notifications
if ($ failCount > 0 ) {
$ this ->notifyMigrationFailure ($ migrations );
} else {
$ this ->notifyMigrationSuccess ($ migrations );
}
return $ migrations ;
} finally {
$ this ->releaseMigrationLock ();
}
}
/**
* Rollback migrations
*/
public function rollbackMigrations (?int $ steps = null , bool $ force = false ): bool
{
if (!$ this ->acquireMigrationLock ()) {
throw new \RuntimeException ('Migration is already in progress ' );
}
try {
Log::info ('Starting migration rollback ' , [
'steps ' => $ steps ,
'force ' => $ force ,
]);
// Create backup before rollback
$ backup = $ this ->backupService ->createBackup ('pre-rollback ' );
$ exitCode = Artisan::call ('migrate:rollback ' , [
'--step ' => $ steps ?? 1 ,
'--force ' => $ force ,
]);
$ output = Artisan::output ();
if ($ exitCode === 0 ) {
Log::info ('Migration rollback successful ' , ['output ' => $ output ]);
return true ;
} else {
Log::error ('Migration rollback failed ' , ['output ' => $ output ]);
return false ;
}
} finally {
$ this ->releaseMigrationLock ();
}
}
/**
* Get migration status
*/
public function getMigrationStatus (): Collection
{
$ ran = DB ::table ('migrations ' )->pluck ('migration ' )->toArray ();
$ allMigrations = $ this ->getAllMigrationFiles ();
return collect ($ allMigrations )->map (function ($ migration ) use ($ ran ) {
$ hasRun = in_array ($ this ->getMigrationName ($ migration ), $ ran );
return [
'migration ' => $ this ->getMigrationName ($ migration ),
'status ' => $ hasRun ? 'ran ' : 'pending ' ,
'batch ' => $ hasRun ? $ this ->getMigrationBatch ($ migration ) : null ,
'file_path ' => $ migration ,
'file_hash ' => md5_file ($ migration ),
];
});
}
/**
* Check for pending migrations
*/
public function hasPendingMigrations (): bool
{
$ pending = $ this ->getPendingMigrationFiles ();
return count ($ pending ) > 0 ;
}
/**
* Get migration history
*/
public function getMigrationHistory (int $ limit = 50 ): Collection
{
return MigrationLog::with (['executedBy ' , 'backup ' ])
->orderByDesc ('created_at ' )
->limit ($ limit )
->get ();
}
/**
* Create pre-migration backup
*/
public function createPreMigrationBackup (): DatabaseBackup
{
Log::info ('Creating pre-migration backup ' );
return $ this ->backupService ->createBackup ('pre-migration ' );
}
/**
* Restore from backup
*/
public function restoreFromBackup (int $ backupId ): bool
{
$ backup = DatabaseBackup::findOrFail ($ backupId );
Log::info ('Restoring database from backup ' , [
'backup_id ' => $ backupId ,
'created_at ' => $ backup ->created_at ,
]);
return $ this ->backupService ->restoreBackup ($ backup );
}
/**
* Detect destructive changes
*/
public function detectDestructiveChanges (string $ migrationPath ): array
{
$ content = File::get ($ migrationPath );
$ destructivePatterns = [
'DROP TABLE ' ,
'DROP COLUMN ' ,
'DROP INDEX ' ,
'TRUNCATE ' ,
'DELETE FROM ' ,
'->drop( ' ,
'->dropColumn( ' ,
'->dropIndex( ' ,
'->dropForeign( ' ,
];
$ found = [];
foreach ($ destructivePatterns as $ pattern ) {
if (stripos ($ content , $ pattern ) !== false ) {
$ found [] = $ pattern ;
}
}
return $ found ;
}
// Private helper methods
private function acquireMigrationLock (): bool
{
return Cache::add (self ::MIGRATION_LOCK_KEY , true , self ::LOCK_TIMEOUT );
}
private function releaseMigrationLock (): void
{
Cache::forget (self ::MIGRATION_LOCK_KEY );
}
private function getPendingMigrationFiles (): array
{
$ ran = DB ::table ('migrations ' )->pluck ('migration ' )->toArray ();
$ allMigrations = $ this ->getAllMigrationFiles ();
return array_filter ($ allMigrations , function ($ migration ) use ($ ran ) {
return !in_array ($ this ->getMigrationName ($ migration ), $ ran );
});
}
private function getAllMigrationFiles (): array
{
$ migrationPath = database_path ('migrations ' );
$ files = File::glob ($ migrationPath . '/*.php ' );
return array_values ($ files );
}
private function getMigrationName (string $ path ): string
{
return str_replace ('.php ' , '' , basename ($ path ));
}
private function getMigrationBatch (string $ migration ): ?int
{
$ name = $ this ->getMigrationName ($ migration );
return DB ::table ('migrations ' )
->where ('migration ' , $ name )
->value ('batch ' );
}
private function validateMigrationSyntax (string $ migrationPath ): array
{
$ process = new Process (['php ' , '-l ' , $ migrationPath ]);
$ process ->run ();
return [
'valid ' => $ process ->isSuccessful (),
'error ' => $ process ->isSuccessful () ? null : $ process ->getErrorOutput (),
];
}
private function checkMigrationDependencies (string $ migrationPath ): array
{
// Check if migration references tables that don't exist yet
// This is a simplified check - production would be more sophisticated
$ content = File::get ($ migrationPath );
// Check for foreign key references to potentially non-existent tables
preg_match_all ('/->foreign\([ \'"](\w+)[ \'"]\)->references/ ' , $ content , $ matches );
$ referencedTables = $ matches [1 ] ?? [];
foreach ($ referencedTables as $ table ) {
if (!$ this ->tableWillExist ($ table )) {
return [
'valid ' => false ,
'error ' => "References table ' {$ table }' which may not exist yet " ,
];
}
}
return ['valid ' => true ];
}
private function tableWillExist (string $ tableName ): bool
{
// Check if table exists or will be created by a previous migration
return DB ::getSchemaBuilder ()->hasTable ($ tableName );
}
private function executeSingleMigration (string $ migrationFile , DatabaseBackup $ backup ): MigrationLog
{
$ migrationName = $ this ->getMigrationName ($ migrationFile );
$ log = MigrationLog::create ([
'migration_name ' => $ migrationName ,
'status ' => 'running ' ,
'environment ' => app ()->environment (),
'executed_by ' => auth ()->id (),
'backup_id ' => $ backup ->id ,
'started_at ' => now (),
'metadata ' => [
'file_path ' => $ migrationFile ,
'file_hash ' => md5_file ($ migrationFile ),
'file_size ' => filesize ($ migrationFile ),
],
]);
try {
Log::info ("Executing migration: {$ migrationName }" );
$ startTime = microtime (true );
// Execute migration
$ exitCode = Artisan::call ('migrate ' , [
'--path ' => str_replace (base_path (), '' , dirname ($ migrationFile )),
'--force ' => true ,
]);
$ output = Artisan::output ();
$ duration = microtime (true ) - $ startTime ;
if ($ exitCode === 0 ) {
$ log ->update ([
'status ' => 'completed ' ,
'completed_at ' => now (),
'duration_seconds ' => round ($ duration ),
'output ' => $ output ,
]);
Log::info ("Migration completed: {$ migrationName }" , [
'duration ' => $ duration ,
]);
} else {
$ log ->update ([
'status ' => 'failed ' ,
'completed_at ' => now (),
'duration_seconds ' => round ($ duration ),
'error_message ' => $ output ,
]);
Log::error ("Migration failed: {$ migrationName }" , [
'error ' => $ output ,
]);
}
} catch (\Exception $ e ) {
$ log ->update ([
'status ' => 'failed ' ,
'completed_at ' => now (),
'error_message ' => $ e ->getMessage (),
]);
Log::error ("Migration exception: {$ migrationName }" , [
'exception ' => $ e ->getMessage (),
'trace ' => $ e ->getTraceAsString (),
]);
}
return $ log ->fresh ();
}
private function handleMigrationFailure (MigrationLog $ failedMigration , DatabaseBackup $ backup ): void
{
Log::warning ('Handling migration failure - attempting automatic rollback ' );
try {
// Restore from backup
$ this ->backupService ->restoreBackup ($ backup );
Log::info ('Database restored from backup after migration failure ' );
} catch (\Exception $ e ) {
Log::critical ('Failed to restore database from backup ' , [
'backup_id ' => $ backup ->id ,
'error ' => $ e ->getMessage (),
]);
// Escalate to critical alert
$ this ->escalateCriticalFailure ($ failedMigration , $ backup , $ e );
}
}
private function simulateMigrations (): Collection
{
$ pending = $ this ->getPendingMigrationFiles ();
return collect ($ pending )->map (function ($ migration ) {
return [
'migration ' => $ this ->getMigrationName ($ migration ),
'status ' => 'dry-run ' ,
'file_path ' => $ migration ,
'destructive_changes ' => $ this ->detectDestructiveChanges ($ migration ),
];
});
}
private function notifyMigrationSuccess (Collection $ migrations ): void
{
// Send notification to administrators
$ admins = \App \Models \User::where ('is_admin ' , true )->get ();
foreach ($ admins as $ admin ) {
$ admin ->notify (new MigrationSuccessNotification ($ migrations ));
}
}
private function notifyMigrationFailure (Collection $ migrations ): void
{
$ admins = \App \Models \User::where ('is_admin ' , true )->get ();
foreach ($ admins as $ admin ) {
$ admin ->notify (new MigrationFailedNotification ($ migrations ));
}
}
private function escalateCriticalFailure (
MigrationLog $ failedMigration ,
DatabaseBackup $ backup ,
\Exception $ restoreException
): void {
// Send critical alert
Log::critical ('CRITICAL: Migration failure AND backup restore failed ' , [
'migration ' => $ failedMigration ->migration_name ,
'backup_id ' => $ backup ->id ,
'restore_error ' => $ restoreException ->getMessage (),
]);
// Trigger PagerDuty/incident management system
// Send SMS/phone alerts to on-call engineers
// Create incident ticket
}
}
Database Backup Service
File: app/Services/Enterprise/DatabaseBackupService.php
<?php
namespace App \Services \Enterprise ;
use App \Contracts \DatabaseBackupServiceInterface ;
use App \Models \DatabaseBackup ;
use Illuminate \Support \Facades \Storage ;
use Illuminate \Support \Facades \DB ;
use Illuminate \Support \Facades \Log ;
use Symfony \Component \Process \Process ;
use Visus \Cuid2 \Cuid2 ;
class DatabaseBackupService implements DatabaseBackupServiceInterface
{
private const COMPRESSION_LEVEL = 6 ;
private const RETENTION_DAYS = 30 ;
/**
* Create database backup
*/
public function createBackup (string $ type = 'manual ' ): DatabaseBackup
{
$ backup = DatabaseBackup::create ([
'uuid ' => (string ) new Cuid2 (),
'database_name ' => config ('database.connections.pgsql.database ' ),
'backup_type ' => $ type ,
'status ' => 'pending ' ,
'compression ' => 'gzip ' ,
'created_by ' => auth ()->id (),
'expires_at ' => now ()->addDays (self ::RETENTION_DAYS ),
'started_at ' => now (),
]);
try {
Log::info ('Starting database backup ' , [
'backup_id ' => $ backup ->id ,
'type ' => $ type ,
]);
$ backup ->update (['status ' => 'in_progress ' ]);
// Generate file paths
$ filename = "backup- {$ backup ->uuid }.sql.gz " ;
$ localPath = storage_path ("app/backups/ {$ filename }" );
$ s3Path = "database-backups/ {$ filename }" ;
// Ensure backup directory exists
if (!is_dir (dirname ($ localPath ))) {
mkdir (dirname ($ localPath ), 0755 , true );
}
// Execute pg_dump
$ this ->executePgDump ($ localPath );
// Calculate checksum
$ checksum = hash_file ('sha256 ' , $ localPath );
// Upload to S3
Storage::disk ('s3 ' )->put (
$ s3Path ,
file_get_contents ($ localPath )
);
// Update backup record
$ backup ->update ([
'status ' => 'completed ' ,
'local_path ' => $ localPath ,
'storage_path ' => $ s3Path ,
'file_size ' => filesize ($ localPath ),
'checksum ' => $ checksum ,
'completed_at ' => now (),
'duration_seconds ' => $ backup ->started_at ->diffInSeconds (now ()),
'metadata ' => $ this ->collectMetadata (),
]);
Log::info ('Database backup completed ' , [
'backup_id ' => $ backup ->id ,
'size ' => $ backup ->file_size ,
's3_path ' => $ s3Path ,
]);
// Cleanup local file
if (config ('migration-automation.cleanup_local_backups ' , true )) {
unlink ($ localPath );
$ backup ->update (['local_path ' => null ]);
}
return $ backup ->fresh ();
} catch (\Exception $ e ) {
Log::error ('Database backup failed ' , [
'backup_id ' => $ backup ->id ,
'error ' => $ e ->getMessage (),
]);
$ backup ->update ([
'status ' => 'failed ' ,
'error_message ' => $ e ->getMessage (),
'completed_at ' => now (),
]);
throw $ e ;
}
}
/**
* Restore database from backup
*/
public function restoreBackup (DatabaseBackup $ backup ): bool
{
if ($ backup ->status !== 'completed ' ) {
throw new \RuntimeException ('Can only restore from completed backups ' );
}
Log::info ('Starting database restore ' , [
'backup_id ' => $ backup ->id ,
'created_at ' => $ backup ->created_at ,
]);
try {
// Download from S3 if not available locally
$ localPath = $ backup ->local_path ;
if (!$ localPath || !file_exists ($ localPath )) {
$ localPath = storage_path ("app/backups/restore- {$ backup ->uuid }.sql.gz " );
$ s3Content = Storage::disk ('s3 ' )->get ($ backup ->storage_path );
file_put_contents ($ localPath , $ s3Content );
}
// Verify checksum
if (!$ this ->verifyBackup ($ backup )) {
throw new \RuntimeException ('Backup integrity check failed ' );
}
// Execute pg_restore
$ this ->executePgRestore ($ localPath );
Log::info ('Database restore completed ' , [
'backup_id ' => $ backup ->id ,
]);
return true ;
} catch (\Exception $ e ) {
Log::error ('Database restore failed ' , [
'backup_id ' => $ backup ->id ,
'error ' => $ e ->getMessage (),
]);
throw $ e ;
}
}
/**
* Cleanup expired backups
*/
public function cleanupExpiredBackups (): int
{
$ expiredBackups = DatabaseBackup::where ('expires_at ' , '< ' , now ())
->where ('status ' , 'completed ' )
->get ();
$ deleted = 0 ;
foreach ($ expiredBackups as $ backup ) {
try {
// Delete from S3
if (Storage::disk ('s3 ' )->exists ($ backup ->storage_path )) {
Storage::disk ('s3 ' )->delete ($ backup ->storage_path );
}
// Delete local file if exists
if ($ backup ->local_path && file_exists ($ backup ->local_path )) {
unlink ($ backup ->local_path );
}
// Delete database record
$ backup ->delete ();
$ deleted ++;
Log::info ('Deleted expired backup ' , [
'backup_id ' => $ backup ->id ,
'expired_at ' => $ backup ->expires_at ,
]);
} catch (\Exception $ e ) {
Log::error ('Failed to delete backup ' , [
'backup_id ' => $ backup ->id ,
'error ' => $ e ->getMessage (),
]);
}
}
return $ deleted ;
}
/**
* Verify backup integrity
*/
public function verifyBackup (DatabaseBackup $ backup ): bool
{
if (!$ backup ->local_path || !file_exists ($ backup ->local_path )) {
return false ;
}
$ currentChecksum = hash_file ('sha256 ' , $ backup ->local_path );
return $ currentChecksum === $ backup ->checksum ;
}
/**
* Estimate backup size
*/
public function estimateBackupSize (): int
{
$ query = "
SELECT pg_database_size(current_database()) as size
" ;
$ result = DB ::selectOne ($ query );
// Estimate compressed size (typically 10-20% of original)
return (int ) ($ result ->size * 0.15 );
}
// Private helper methods
private function executePgDump (string $ outputPath ): void
{
$ dbConfig = config ('database.connections.pgsql ' );
$ process = new Process ([
'pg_dump ' ,
'-h ' , $ dbConfig ['host ' ],
'-p ' , $ dbConfig ['port ' ],
'-U ' , $ dbConfig ['username ' ],
'-d ' , $ dbConfig ['database ' ],
'--format=custom ' ,
'--compress= ' . self ::COMPRESSION_LEVEL ,
'--file= ' . $ outputPath ,
], null , [
'PGPASSWORD ' => $ dbConfig ['password ' ],
], null , 3600 ); // 1 hour timeout
$ process ->mustRun ();
Log::info ('pg_dump executed successfully ' , [
'output_path ' => $ outputPath ,
]);
}
private function executePgRestore (string $ backupPath ): void
{
$ dbConfig = config ('database.connections.pgsql ' );
// Drop all tables first
$ this ->dropAllTables ();
$ process = new Process ([
'pg_restore ' ,
'-h ' , $ dbConfig ['host ' ],
'-p ' , $ dbConfig ['port ' ],
'-U ' , $ dbConfig ['username ' ],
'-d ' , $ dbConfig ['database ' ],
'--clean ' ,
'--if-exists ' ,
$ backupPath ,
], null , [
'PGPASSWORD ' => $ dbConfig ['password ' ],
], null , 3600 );
$ process ->mustRun ();
Log::info ('pg_restore executed successfully ' );
}
private function dropAllTables (): void
{
DB ::statement ('DROP SCHEMA public CASCADE ' );
DB ::statement ('CREATE SCHEMA public ' );
DB ::statement ('GRANT ALL ON SCHEMA public TO ' . config ('database.connections.pgsql.username ' ));
}
private function collectMetadata (): array
{
$ tableCount = count (DB ::select ("
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
" ));
return [
'postgresql_version ' => DB ::selectOne ('SELECT version() ' )->version ,
'table_count ' => $ tableCount ,
'backup_timestamp ' => now ()->toIso8601String (),
];
}
}
Artisan Commands
File: app/Console/Commands/MigrateWithBackup.php
<?php
namespace App \Console \Commands ;
use App \Contracts \MigrationAutomationServiceInterface ;
use Illuminate \Console \Command ;
class MigrateWithBackup extends Command
{
protected $ signature = 'migrate:safe
{--force : Force migration without confirmation}
{--dry-run : Simulate migration without execution}
{--skip-backup : Skip pre-migration backup (not recommended)} ' ;
protected $ description = 'Run migrations with automatic backup and rollback capability ' ;
public function handle (MigrationAutomationServiceInterface $ migrationService ): int
{
$ this ->info ('🔍 Validating pending migrations... ' );
// Validate migrations
$ validation = $ migrationService ->validatePendingMigrations ();
if ($ validation ['pending_count ' ] === 0 ) {
$ this ->info ('✅ No pending migrations to execute ' );
return self ::SUCCESS ;
}
$ this ->info ("📋 Found {$ validation ['pending_count ' ]} pending migration(s) " );
// Show validation results
if (!empty ($ validation ['warnings ' ])) {
$ this ->warn ('⚠️ Warnings: ' );
foreach ($ validation ['warnings ' ] as $ warning ) {
$ this ->warn (" • {$ warning }" );
}
}
if (!empty ($ validation ['errors ' ])) {
$ this ->error ('❌ Validation errors: ' );
foreach ($ validation ['errors ' ] as $ error ) {
$ this ->error (" • {$ error }" );
}
if (!$ this ->option ('force ' )) {
$ this ->error ('Migration validation failed. Use --force to override. ' );
return self ::FAILURE ;
}
}
// Confirmation in production
if (app ()->environment ('production ' ) && !$ this ->option ('force ' )) {
if (!$ this ->confirm ('⚠️ This is a PRODUCTION environment. Continue with migration? ' )) {
$ this ->info ('Migration cancelled ' );
return self ::SUCCESS ;
}
}
// Execute migrations
try {
$ this ->info ('🚀 Executing migrations... ' );
$ migrations = $ migrationService ->executeMigrations (
$ this ->option ('force ' ),
$ this ->option ('dry-run ' )
);
// Display results
$ this ->newLine ();
$ this ->table (
['Migration ' , 'Status ' , 'Duration ' ],
$ migrations ->map (fn ($ m ) => [
$ m ->migration_name ?? $ m ['migration ' ],
$ m ->status ?? 'dry-run ' ,
isset ($ m ->duration_seconds ) ? "{$ m ->duration_seconds }s " : 'N/A ' ,
])->toArray ()
);
$ successCount = $ migrations ->where ('status ' , 'completed ' )->count ();
$ failCount = $ migrations ->where ('status ' , 'failed ' )->count ();
if ($ failCount > 0 ) {
$ this ->error ("❌ Migration failed: {$ failCount } failure(s) " );
return self ::FAILURE ;
}
$ this ->info ("✅ Successfully executed {$ successCount } migration(s) " );
return self ::SUCCESS ;
} catch (\Exception $ e ) {
$ this ->error ("❌ Migration error: {$ e ->getMessage ()}" );
return self ::FAILURE ;
}
}
}
File: app/Console/Commands/ValidateMigrations.php
<?php
namespace App \Console \Commands ;
use App \Contracts \MigrationAutomationServiceInterface ;
use Illuminate \Console \Command ;
class ValidateMigrations extends Command
{
protected $ signature = 'migrate:validate ' ;
protected $ description = 'Validate pending migrations without executing them ' ;
public function handle (MigrationAutomationServiceInterface $ migrationService ): int
{
$ this ->info ('🔍 Validating pending migrations... ' );
$ validation = $ migrationService ->validatePendingMigrations ();
if ($ validation ['pending_count ' ] === 0 ) {
$ this ->info ('✅ No pending migrations ' );
return self ::SUCCESS ;
}
$ this ->info ("📋 Found {$ validation ['pending_count ' ]} pending migration(s) " );
$ this ->newLine ();
if (!empty ($ validation ['warnings ' ])) {
$ this ->warn ('⚠️ Warnings: ' );
foreach ($ validation ['warnings ' ] as $ warning ) {
$ this ->warn (" • {$ warning }" );
}
$ this ->newLine ();
}
if (!empty ($ validation ['errors ' ])) {
$ this ->error ('❌ Validation errors: ' );
foreach ($ validation ['errors ' ] as $ error ) {
$ this ->error (" • {$ error }" );
}
$ this ->newLine ();
return self ::FAILURE ;
}
$ this ->info ('✅ All pending migrations are valid ' );
return self ::SUCCESS ;
}
}
Implementation Approach
Step 1: Database Schema
Create migration_logs table migration
Create database_backups table migration
Run migrations: php artisan migrate
Step 2: Create Models
Create MigrationLog model with relationships
Create DatabaseBackup model with casts
Add factory and seeder for testing
Step 3: Implement Backup Service
Create DatabaseBackupServiceInterface
Implement DatabaseBackupService
Add pg_dump/pg_restore wrapper methods
Implement S3 upload/download
Add checksum validation
Step 4: Implement Migration Automation Service
Create MigrationAutomationServiceInterface
Implement MigrationAutomationService
Add validation methods
Add execution orchestration
Add rollback logic
Step 5: Create Artisan Commands
Implement MigrateWithBackup command
Implement ValidateMigrations command
Implement RollbackMigration command
Implement MigrationStatus command
Register commands in Kernel.php
Step 6: Add Web UI
Create MigrationController
Build MigrationManager.vue component
Build MigrationHistory.vue component
Add routes for migration management
Step 7: Add Notifications
Create MigrationSuccessNotification
Create MigrationFailedNotification
Configure mail/Slack channels
Step 8: Testing
Unit tests for services
Feature tests for commands
Integration tests for full workflow
Test failure scenarios and rollback
Test Strategy
Unit Tests
File: tests/Unit/Services/MigrationAutomationServiceTest.php
<?php
use App \Services \Enterprise \MigrationAutomationService ;
use App \Services \Enterprise \DatabaseBackupService ;
use Illuminate \Support \Facades \Artisan ;
use Illuminate \Support \Facades \File ;
beforeEach (function () {
$ this ->backupService = Mockery::mock (DatabaseBackupService::class);
$ this ->service = new MigrationAutomationService ($ this ->backupService );
});
it ('validates pending migrations ' , function () {
$ result = $ this ->service ->validatePendingMigrations ();
expect ($ result )->toHaveKeys (['valid ' , 'errors ' , 'warnings ' , 'pending_count ' ]);
});
it ('detects destructive changes ' , function () {
$ migrationContent = <<<'PHP'
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email');
});
PHP;
$ tempFile = tempnam (sys_get_temp_dir (), 'migration ' );
file_put_contents ($ tempFile , $ migrationContent );
$ destructive = $ this ->service ->detectDestructiveChanges ($ tempFile );
expect ($ destructive )->toContain ('->dropColumn( ' );
unlink ($ tempFile );
});
it ('checks for pending migrations ' , function () {
$ hasPending = $ this ->service ->hasPendingMigrations ();
expect ($ hasPending )->toBeTrue ();
});
Feature Tests
File: tests/Feature/MigrationAutomationTest.php
<?php
use App \Models \MigrationLog ;
use App \Models \DatabaseBackup ;
use Illuminate \Support \Facades \Artisan ;
it ('executes migrations with backup ' , function () {
$ this ->artisan ('migrate:safe ' , ['--force ' => true ])
->assertExitCode (0 );
expect (DatabaseBackup::where ('backup_type ' , 'pre-migration ' )->exists ())->toBeTrue ();
expect (MigrationLog::where ('status ' , 'completed ' )->exists ())->toBeTrue ();
});
it ('validates migrations before execution ' , function () {
$ this ->artisan ('migrate:validate ' )
->expectsOutput ('✅ All pending migrations are valid ' )
->assertExitCode (0 );
});
it ('creates backup before migration ' , function () {
$ service = app (\App \Contracts \MigrationAutomationServiceInterface::class);
$ backup = $ service ->createPreMigrationBackup ();
expect ($ backup )->toBeInstanceOf (DatabaseBackup::class)
->status ->toBe ('completed ' )
->backup_type ->toBe ('pre-migration ' );
});
it ('rolls back on migration failure ' , function () {
// Create a migration that will fail
$ failingMigration = database_path ('migrations/ ' . date ('Y_m_d_His ' ) . '_failing_migration.php ' );
file_put_contents ($ failingMigration , <<<'PHP'
<?php
use Illuminate\Database\Migrations\Migration;
return new class extends Migration {
public function up() {
throw new \Exception('Intentional failure');
}
};
PHP);
$ this ->artisan ('migrate:safe ' , ['--force ' => true ]);
expect (MigrationLog::where ('status ' , 'failed ' )->exists ())->toBeTrue ();
unlink ($ failingMigration );
});
Definition of Done
Related Tasks
Depends on: Task 89 (CI/CD pipeline for deployment automation)
Used by: Task 91 (Monitoring dashboards display migration status)
Integrates with: All tasks (database migrations are foundational)
Task: Implement database migration automation
Description
Implement a comprehensive database migration automation system for the Coolify Enterprise platform that ensures safe, reliable, and auditable database schema changes across development, staging, and production environments. This system provides automated migration validation, pre-flight checks, backup creation, automatic execution with rollback capability, and comprehensive audit logging for all database schema changes.
Modern multi-environment deployments require robust migration management to prevent downtime, data loss, and deployment failures. Manual migration execution is error-prone and lacks safety mechanisms. This task creates an intelligent migration orchestration system that validates migrations before execution, creates automatic backups, handles rollbacks on failures, and provides detailed audit trails for compliance and debugging.
Core Capabilities:
Integration Points:
Why This Task Is Critical:
Database migrations are the riskiest part of any deployment. A failed migration can cause complete application outages, data corruption, or irreversible data loss. Manual migration execution lacks safety checks, audit trails, and rollback capabilities. This automation system transforms migrations from a high-risk manual process into a reliable, auditable, automated workflow with comprehensive safety mechanisms. It's essential for production-grade enterprise deployments where uptime, data integrity, and compliance are non-negotiable.
Real-World Problem Solved:
Acceptance Criteria
Technical Details
File Paths
Service Layer:
/home/topgun/topgun/app/Services/Enterprise/MigrationAutomationService.php(core service)/home/topgun/topgun/app/Contracts/MigrationAutomationServiceInterface.php(interface)/home/topgun/topgun/app/Services/Enterprise/DatabaseBackupService.php(backup service)/home/topgun/topgun/app/Contracts/DatabaseBackupServiceInterface.php(backup interface)Artisan Commands:
/home/topgun/topgun/app/Console/Commands/MigrateWithBackup.php(automated migration)/home/topgun/topgun/app/Console/Commands/ValidateMigrations.php(validation)/home/topgun/topgun/app/Console/Commands/RollbackMigration.php(rollback)/home/topgun/topgun/app/Console/Commands/MigrationStatus.php(status)Models:
/home/topgun/topgun/app/Models/MigrationLog.php(migration audit log)/home/topgun/topgun/app/Models/DatabaseBackup.php(backup tracking)Controllers:
/home/topgun/topgun/app/Http/Controllers/Enterprise/MigrationController.php(web UI)Vue Components:
/home/topgun/topgun/resources/js/Components/Enterprise/Migration/MigrationManager.vue(UI)/home/topgun/topgun/resources/js/Components/Enterprise/Migration/MigrationHistory.vue(history)Configuration:
/home/topgun/topgun/config/migration-automation.php(configuration)Jobs:
/home/topgun/topgun/app/Jobs/ExecuteMigrationJob.php(async migration execution)/home/topgun/topgun/app/Jobs/BackupDatabaseJob.php(async backup)Database Schema
Migration Logs Table:
Database Backups Table:
Service Interface
File:
app/Contracts/MigrationAutomationServiceInterface.phpBackup Service Interface:
Service Implementation
File:
app/Services/Enterprise/MigrationAutomationService.phpDatabase Backup Service
File:
app/Services/Enterprise/DatabaseBackupService.phpArtisan Commands
File:
app/Console/Commands/MigrateWithBackup.phpFile:
app/Console/Commands/ValidateMigrations.phpImplementation Approach
Step 1: Database Schema
migration_logstable migrationdatabase_backupstable migrationphp artisan migrateStep 2: Create Models
MigrationLogmodel with relationshipsDatabaseBackupmodel with castsStep 3: Implement Backup Service
DatabaseBackupServiceInterfaceDatabaseBackupServiceStep 4: Implement Migration Automation Service
MigrationAutomationServiceInterfaceMigrationAutomationServiceStep 5: Create Artisan Commands
MigrateWithBackupcommandValidateMigrationscommandRollbackMigrationcommandMigrationStatuscommandKernel.phpStep 6: Add Web UI
MigrationControllerMigrationManager.vuecomponentMigrationHistory.vuecomponentStep 7: Add Notifications
MigrationSuccessNotificationMigrationFailedNotificationStep 8: Testing
Test Strategy
Unit Tests
File:
tests/Unit/Services/MigrationAutomationServiceTest.phpFeature Tests
File:
tests/Feature/MigrationAutomationTest.phpDefinition of Done
Related Tasks