From 81a00cd5da50a869e0d2a385b634f9b6b20a0692 Mon Sep 17 00:00:00 2001 From: Arthur Monney Date: Mon, 15 Sep 2025 09:55:41 +0200 Subject: [PATCH 1/2] feat: [CU-86b6p0n5r] create migration module with ssh tunnel connection --- app-modules/database-migration/README.md | 269 ++++++++++++++++++ app-modules/database-migration/composer.json | 29 ++ .../database-migration/config/ssh-tunnel.php | 92 ++++++ .../Commands/MigrateDatabaseCommand.php | 100 +++++++ .../src/Console/Commands/SshTunnelCommand.php | 102 +++++++ .../src/Exceptions/SshTunnelException.php | 34 +++ .../src/Facades/SshTunnel.php | 21 ++ .../src/Jobs/CreateSshTunnel.php | 22 ++ .../DatabaseMigrationServiceProvider.php | 51 ++++ .../src/Services/DatabaseMigrationService.php | 183 ++++++++++++ .../src/Services/SshTunnelService.php | 152 ++++++++++ .../Feature/MigrateDatabaseCommandTest.php | 90 ++++++ .../tests/Feature/SshTunnelCommandTest.php | 36 +++ .../Unit/DatabaseMigrationServiceTest.php | 72 +++++ .../tests/Unit/SshTunnelServiceTest.php | 52 ++++ composer.json | 1 + composer.lock | 41 ++- config/database.php | 21 ++ phpstan.neon | 1 + pint.json | 1 - 20 files changed, 1368 insertions(+), 2 deletions(-) create mode 100644 app-modules/database-migration/README.md create mode 100644 app-modules/database-migration/composer.json create mode 100644 app-modules/database-migration/config/ssh-tunnel.php create mode 100644 app-modules/database-migration/src/Console/Commands/MigrateDatabaseCommand.php create mode 100644 app-modules/database-migration/src/Console/Commands/SshTunnelCommand.php create mode 100644 app-modules/database-migration/src/Exceptions/SshTunnelException.php create mode 100644 app-modules/database-migration/src/Facades/SshTunnel.php create mode 100644 app-modules/database-migration/src/Jobs/CreateSshTunnel.php create mode 100644 app-modules/database-migration/src/Providers/DatabaseMigrationServiceProvider.php create mode 100644 app-modules/database-migration/src/Services/DatabaseMigrationService.php create mode 100644 app-modules/database-migration/src/Services/SshTunnelService.php create mode 100644 app-modules/database-migration/tests/Feature/MigrateDatabaseCommandTest.php create mode 100644 app-modules/database-migration/tests/Feature/SshTunnelCommandTest.php create mode 100644 app-modules/database-migration/tests/Unit/DatabaseMigrationServiceTest.php create mode 100644 app-modules/database-migration/tests/Unit/SshTunnelServiceTest.php diff --git a/app-modules/database-migration/README.md b/app-modules/database-migration/README.md new file mode 100644 index 00000000..f52515b2 --- /dev/null +++ b/app-modules/database-migration/README.md @@ -0,0 +1,269 @@ +# Database Migration Module + +This module provides comprehensive MySQL to PostgreSQL migration capabilities with SSH tunnel support, specifically designed for migrating data from remote MySQL databases to local PostgreSQL databases securely. + +## Features + +### SSH Tunnel Management +- **SSH Tunnel Creation**: Secure tunnels to remote MySQL databases +- **Status Monitoring**: Check tunnel connectivity and status +- **Auto-activation**: Optional automatic tunnel activation on application boot +- **Background Processing**: Queue-based tunnel creation for better performance + +### Database Migration +- **Full Database Migration**: Migrate all tables from MySQL to PostgreSQL +- **Selective Migration**: Migrate specific tables only +- **Data Transformation**: Automatic data type conversion (MySQL → PostgreSQL) +- **Chunked Processing**: Process large datasets in configurable chunks +- **Progress Tracking**: Real-time migration progress with detailed output +- **Dry Run Mode**: Preview migration without actually transferring data +- **Verification**: Compare record counts between source and target databases + +### Developer Experience +- **Artisan Commands**: Easy-to-use CLI commands for all operations +- **Exception Handling**: Custom exceptions for better error management +- **Facade Support**: Simple API access through Laravel facades +- **Comprehensive Testing**: Full test coverage with PestPHP +- **Detailed Logging**: Configurable logging for debugging and monitoring + +## Installation + +Since this module is integrated into the main Laravel.cm project, it's automatically available once the project is set up. + +### Configuration + +Publish the configuration file: + +```bash +php artisan vendor:publish --tag=ssh-tunnel-config +``` + +Configure your environment variables in `.env`: + +```env +# SSH Tunnel Configuration +SSH_TUNNEL_USER=your-ssh-user +SSH_TUNNEL_HOSTNAME=your-server.com +SSH_TUNNEL_IDENTITY_FILE=/path/to/your/private/key +SSH_TUNNEL_LOCAL_PORT=3307 +SSH_TUNNEL_BIND_PORT=3306 +SSH_TUNNEL_BIND_ADDRESS=127.0.0.1 +SSH_TUNNEL_AUTO_ACTIVATE=false +SSH_TUNNEL_LOGGING_ENABLED=true +SSH_TUNNEL_LOGGING_CHANNEL=default + +# Database Connections (already configured in config/database.php) +# Secondary connection points to MySQL via SSH tunnel +DB_HOST_SECOND=127.0.0.1 +DB_PORT_SECOND=3307 +DB_DATABASE_SECOND=your_mysql_database +DB_USERNAME_SECOND=your_mysql_user +DB_PASSWORD_SECOND=your_mysql_password +``` + +## Usage + +### SSH Tunnel Management + +#### Artisan Commands +```bash +# Activate the tunnel +php artisan ssh-tunnel:activate + +# Check tunnel status +php artisan ssh-tunnel:activate --check + +# Destroy the tunnel +php artisan ssh-tunnel:activate --destroy +``` + +### Database Migration + +#### Full Migration +```bash +# Migrate all tables (dry run first to preview) +php artisan db:migrate-mysql-to-pgsql --dry-run + +# Perform actual migration +php artisan db:migrate-mysql-to-pgsql +``` + +#### Selective Migration +```bash +# Migrate specific tables +php artisan db:migrate-mysql-to-pgsql --tables=users --tables=articles + +# Custom chunk size for large tables +php artisan db:migrate-mysql-to-pgsql --chunk=500 +``` + +#### Migration Options +- `--tables=table1,table2`: Migrate only specified tables +- `--chunk=1000`: Number of records to process per chunk (default: 1000) +- `--dry-run`: Preview migration without transferring data + +### Programmatic Usage + +#### SSH Tunnel Service +```php +use Laravelcm\DatabaseMigration\Services\SshTunnelService; + +$tunnelService = app(SshTunnelService::class); + +// Activate tunnel +$status = $tunnelService->activate(); + +// Check if tunnel is active +$isActive = $tunnelService->isActive(); + +// Destroy tunnel +$destroyed = $tunnelService->destroy(); +``` + +#### Database Migration Service +```php +use Laravelcm\DatabaseMigration\Services\DatabaseMigrationService; + +$migrationService = app(DatabaseMigrationService::class); + +// Get all source tables +$tables = $migrationService->getSourceTables(); + +// Migrate a specific table +$migrationService->migrateTable('users', 1000, function($processed, $total) { + echo "Processed {$processed}/{$total} records\n"; +}); + +// Verify migration +$verification = $migrationService->verifyMigration(['users', 'articles']); + +// Test connections +$connectionStatus = $migrationService->testConnections(); +``` + +#### Using Facades +```php +use Laravelcm\DatabaseMigration\Facades\SshTunnel; + +// Activate tunnel +SshTunnel::activate(); + +// Check status +$isActive = SshTunnel::isActive(); + +// Destroy tunnel +SshTunnel::destroy(); +``` + +#### Background Jobs +```php +use Laravelcm\DatabaseMigration\Jobs\CreateSshTunnel; + +// Dispatch job to create tunnel in background +CreateSshTunnel::dispatch(); +``` + +## Data Transformation + +The migration service automatically handles common MySQL to PostgreSQL data transformations: + +- **Boolean Fields**: MySQL tinyint(1) → PostgreSQL boolean +- **Empty Strings**: Empty strings → NULL (where appropriate) +- **Invalid Timestamps**: MySQL '0000-00-00 00:00:00' → NULL +- **Character Encoding**: Proper UTF-8 handling + +## Testing + +The module includes comprehensive tests using PestPHP with 16 test cases covering: + +- SSH tunnel functionality +- Database migration operations +- Command-line interfaces +- Error handling scenarios + +Run the module tests: + +```bash +php artisan test app-modules/database-migration/tests +``` + +### Test Coverage +- **Unit Tests**: Service classes and core functionality +- **Feature Tests**: Artisan commands and integration scenarios +- **Mocking**: Proper isolation of external dependencies + +## Configuration Options + +All configuration options are available in `config/ssh-tunnel.php`: + +### SSH Settings +- `ssh.user`: SSH username +- `ssh.hostname`: Remote server hostname +- `ssh.identity_file`: Path to SSH private key +- `ssh.local_port`: Local port for tunnel +- `ssh.bind_port`: Remote port to bind to +- `ssh.bind_address`: Bind address (usually 127.0.0.1) + +### System Executables +- `executables.ssh`: Path to SSH binary +- `executables.ps`: Path to ps command +- `executables.grep`: Path to grep command +- `executables.awk`: Path to awk command + +### Connection Settings +- `connection.max_tries`: Maximum connection retry attempts +- `connection.wait_microseconds`: Wait time between retries + +### Logging +- `logging.enabled`: Enable/disable logging +- `logging.channel`: Laravel log channel to use + +### Auto-activation +- `auto_activate`: Automatically activate tunnel on app boot + +## Error Handling + +The module includes comprehensive error handling: + +### Custom Exceptions +- `SshTunnelException`: SSH tunnel-specific errors +- Detailed error messages with troubleshooting information +- Proper exception chaining and context + +### Common Issues +1. **SSH Key Issues**: Ensure proper key permissions (600) +2. **Port Conflicts**: Check if local port is already in use +3. **Network Connectivity**: Verify SSH access to remote server +4. **Database Permissions**: Ensure proper MySQL user permissions + +## Performance Considerations + +- **Chunked Processing**: Large tables processed in configurable chunks +- **Memory Management**: Efficient memory usage for large datasets +- **Progress Tracking**: Real-time feedback for long-running operations +- **Connection Pooling**: Optimized database connection handling + +## Security + +- **SSH Key Authentication**: Secure key-based authentication +- **Encrypted Tunnels**: All data transfer through encrypted SSH tunnels +- **No Password Storage**: No database passwords in tunnel configuration +- **Audit Logging**: Comprehensive logging for security auditing + +## Requirements + +- PHP 8.4+ +- Laravel 11+ +- SSH client installed on the system +- Valid SSH key for remote server access +- PostgreSQL and MySQL PHP extensions +- Sufficient memory for large dataset processing + +## Migration Workflow + +1. **Setup**: Configure SSH tunnel and database connections +2. **Test**: Verify connections with `ssh-tunnel:activate --check` +3. **Preview**: Run migration with `--dry-run` flag +4. **Execute**: Perform actual migration +5. **Verify**: Check data integrity and record counts +6. **Cleanup**: Optionally destroy SSH tunnel diff --git a/app-modules/database-migration/composer.json b/app-modules/database-migration/composer.json new file mode 100644 index 00000000..fcf54abf --- /dev/null +++ b/app-modules/database-migration/composer.json @@ -0,0 +1,29 @@ +{ + "name": "laravelcm/database-migration", + "description": "Module laravelcm/database-migration for Laravel.cm - Migration tools from MySQL to PostgreSQL with SSH tunnel support", + "type": "library", + "version": "1.0.0", + "license": "proprietary", + "require": { + "php": "^8.4" + }, + "require-dev": { + "pestphp/pest": "^3.8", + "pestphp/pest-plugin-laravel": "^3.0" + }, + "autoload": { + "psr-4": { + "Laravelcm\\DatabaseMigration\\": "src/", + "Laravelcm\\DatabaseMigration\\Database\\Factories\\": "database/factories/", + "Laravelcm\\DatabaseMigration\\Database\\Seeders\\": "database/seeders/" + } + }, + "minimum-stability": "stable", + "extra": { + "laravel": { + "providers": [ + "Laravelcm\\DatabaseMigration\\Providers\\DatabaseMigrationServiceProvider" + ] + } + } +} diff --git a/app-modules/database-migration/config/ssh-tunnel.php b/app-modules/database-migration/config/ssh-tunnel.php new file mode 100644 index 00000000..5dd088d5 --- /dev/null +++ b/app-modules/database-migration/config/ssh-tunnel.php @@ -0,0 +1,92 @@ + env('SSH_TUNNEL_VERIFY_PROCESS', 'nc'), + + /* + |-------------------------------------------------------------------------- + | Executable Paths + |-------------------------------------------------------------------------- + */ + 'executables' => [ + 'nc' => env('SSH_TUNNEL_NC_PATH', '/usr/bin/nc'), + 'bash' => env('SSH_TUNNEL_BASH_PATH', '/usr/bin/bash'), + 'ssh' => env('SSH_TUNNEL_SSH_PATH', '/usr/bin/ssh'), + 'nohup' => env('SSH_TUNNEL_NOHUP_PATH', '/usr/bin/nohup'), + ], + + /* + |-------------------------------------------------------------------------- + | Local Configuration + |-------------------------------------------------------------------------- + */ + 'local' => [ + 'address' => env('SSH_TUNNEL_LOCAL_ADDRESS', '127.0.0.1'), + 'port' => env('SSH_TUNNEL_LOCAL_PORT', 3307), + ], + + /* + |-------------------------------------------------------------------------- + | Remote Configuration + |-------------------------------------------------------------------------- + */ + 'remote' => [ + 'bind_address' => env('SSH_TUNNEL_BIND_ADDRESS', '127.0.0.1'), + 'bind_port' => env('SSH_TUNNEL_BIND_PORT', 3306), + ], + + /* + |-------------------------------------------------------------------------- + | SSH Connection + |-------------------------------------------------------------------------- + */ + 'ssh' => [ + 'user' => env('SSH_TUNNEL_USER'), + 'hostname' => env('SSH_TUNNEL_HOSTNAME'), + 'port' => env('SSH_TUNNEL_PORT', 22), + 'identity_file' => env('SSH_TUNNEL_IDENTITY_FILE', '~/.ssh/id_rsa'), + 'options' => env('SSH_TUNNEL_SSH_OPTIONS', '-o StrictHostKeyChecking=no'), + 'verbosity' => env('SSH_TUNNEL_VERBOSITY', ''), + ], + + /* + |-------------------------------------------------------------------------- + | Connection Settings + |-------------------------------------------------------------------------- + */ + 'connection' => [ + 'wait_microseconds' => env('SSH_TUNNEL_WAIT', 1000000), // 1 second + 'max_tries' => env('SSH_TUNNEL_MAX_TRIES', 3), + 'timeout_seconds' => env('SSH_TUNNEL_TIMEOUT', 30), + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + 'logging' => [ + 'enabled' => env('SSH_TUNNEL_LOGGING', true), + 'nohup_log' => env('SSH_TUNNEL_NOHUP_LOG', '/dev/null'), + 'channel' => env('SSH_TUNNEL_LOG_CHANNEL', 'single'), + ], + + /* + |-------------------------------------------------------------------------- + | Auto-activation + |-------------------------------------------------------------------------- + */ + 'auto_activate' => env('SSH_TUNNEL_AUTO_ACTIVATE', false), +]; diff --git a/app-modules/database-migration/src/Console/Commands/MigrateDatabaseCommand.php b/app-modules/database-migration/src/Console/Commands/MigrateDatabaseCommand.php new file mode 100644 index 00000000..e403168a --- /dev/null +++ b/app-modules/database-migration/src/Console/Commands/MigrateDatabaseCommand.php @@ -0,0 +1,100 @@ +info('🚀 Starting MySQL to PostgreSQL migration...'); + + // Ensure SSH tunnel is active + if (! $tunnelService->isActive()) { + $this->warn('SSH tunnel is not active. Attempting to activate...'); + $tunnelService->activate(); + } + + if (! $tunnelService->isActive()) { + $this->error('❌ Failed to activate SSH tunnel. Migration aborted.'); + + return Command::FAILURE; + } + + $this->info('✅ SSH tunnel is active'); + + $isDryRun = $this->option('dry-run'); + $chunkSize = (int) $this->option('chunk'); + $specificTables = $this->option('tables'); + + if ($isDryRun) { + $this->warn('🔍 DRY RUN MODE - No data will be actually migrated'); + } + + try { + // Get tables to migrate + $tables = $specificTables ?: $migrationService->getSourceTables(); + + if (blank($tables)) { + $this->error('❌ No tables found to migrate'); + + return Command::FAILURE; + } + + $this->info(sprintf('📋 Found %d tables to migrate', count($tables))); + + $progressBar = $this->output->createProgressBar(count($tables)); + $progressBar->start(); + + foreach ($tables as $table) { + if ($table === null) { + continue; + } + + $this->newLine(); + $this->info("🔄 Migrating table: {$table}"); + + if (! $isDryRun) { + $migrationService->migrateTable($table, $chunkSize, function ($processed, $total): void { + $this->line(" 📊 Processed {$processed}/{$total} records"); + }); + } else { + $count = $migrationService->getTableRecordCount($table); + $this->line(" 📊 Would migrate {$count} records"); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + if ($isDryRun) { + $this->info('✅ Dry run completed successfully'); + } else { + $this->info('✅ Migration completed successfully'); + } + + return Command::SUCCESS; + + } catch (\Exception $e) { + $this->error("❌ Migration failed: {$e->getMessage()}"); + + return Command::FAILURE; + } + } +} diff --git a/app-modules/database-migration/src/Console/Commands/SshTunnelCommand.php b/app-modules/database-migration/src/Console/Commands/SshTunnelCommand.php new file mode 100644 index 00000000..42f138f8 --- /dev/null +++ b/app-modules/database-migration/src/Console/Commands/SshTunnelCommand.php @@ -0,0 +1,102 @@ +option('destroy')) { + return $this->destroyTunnel($tunnelService); + } + + if ($this->option('check')) { + return $this->checkTunnel($tunnelService); + } + + return $this->activateTunnel($tunnelService); + + } catch (SshTunnelException $e) { + $this->error($e->getMessage()); + + return Command::FAILURE; + } + } + + private function activateTunnel(SshTunnelService $tunnelService): int + { + $this->info('🔍 Activating SSH tunnel...'); + + $result = $tunnelService->activate(); + + return match ($result) { + 1 => $this->handleAlreadyActive(), + 2 => $this->handleSuccessfulActivation(), + default => $this->handleUnexpectedResult(), + }; + } + + private function checkTunnel(SshTunnelService $tunnelService): int + { + $this->info('🔍 Checking SSH tunnel status...'); + + if ($tunnelService->isActive()) { + $this->info('✅ SSH tunnel is active'); + + return Command::SUCCESS; + } + + $this->error('❌ SSH tunnel is not active'); + + return Command::FAILURE; + } + + private function destroyTunnel(SshTunnelService $tunnelService): int + { + $this->info('🔥 Destroying SSH tunnel...'); + + if ($tunnelService->destroy()) { + $this->info('✅ SSH tunnel destroyed successfully'); + + return Command::SUCCESS; + } + + $this->error('❌ Failed to destroy SSH tunnel'); + + return Command::FAILURE; + } + + private function handleAlreadyActive(): int + { + $this->info('✅ SSH tunnel is already active'); + + return Command::SUCCESS; + } + + private function handleSuccessfulActivation(): int + { + $this->info('✅ SSH tunnel activated successfully'); + + return Command::SUCCESS; + } + + private function handleUnexpectedResult(): int + { + $this->warn('⚠️ Unexpected result from tunnel activation'); + + return Command::FAILURE; + } +} diff --git a/app-modules/database-migration/src/Exceptions/SshTunnelException.php b/app-modules/database-migration/src/Exceptions/SshTunnelException.php new file mode 100644 index 00000000..0b05fb56 --- /dev/null +++ b/app-modules/database-migration/src/Exceptions/SshTunnelException.php @@ -0,0 +1,34 @@ +activate(); + } +} diff --git a/app-modules/database-migration/src/Providers/DatabaseMigrationServiceProvider.php b/app-modules/database-migration/src/Providers/DatabaseMigrationServiceProvider.php new file mode 100644 index 00000000..f178cb0e --- /dev/null +++ b/app-modules/database-migration/src/Providers/DatabaseMigrationServiceProvider.php @@ -0,0 +1,51 @@ +mergeConfigFrom( + __DIR__.'/../../config/ssh-tunnel.php', + 'ssh-tunnel' + ); + + $this->app->singleton(SshTunnelService::class); + $this->app->singleton(DatabaseMigrationService::class); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + SshTunnelCommand::class, + MigrateDatabaseCommand::class, + ]); + + $this->publishes([ + __DIR__.'/../../config/ssh-tunnel.php' => config_path('ssh-tunnel.php'), + ], 'ssh-tunnel-config'); + } + + // Auto-activate tunnel if configured + if (config('ssh-tunnel.auto_activate', false)) { + $this->app->booted(function (): void { + try { + $this->app->make(SshTunnelService::class)->activate(); + } catch (\Exception $e) { + // Log error but don't break the application + logger()->error('Failed to auto-activate SSH tunnel: '.$e->getMessage()); + } + }); + } + } +} diff --git a/app-modules/database-migration/src/Services/DatabaseMigrationService.php b/app-modules/database-migration/src/Services/DatabaseMigrationService.php new file mode 100644 index 00000000..46b12ff4 --- /dev/null +++ b/app-modules/database-migration/src/Services/DatabaseMigrationService.php @@ -0,0 +1,183 @@ + + */ + public function getSourceTables(): array + { + $tables = DB::connection($this->sourceConnection) + ->select('SHOW TABLES'); + + $tableColumn = 'Tables_in_'.DB::connection($this->sourceConnection)->getDatabaseName(); + + return collect($tables) + ->pluck($tableColumn) + ->reject(fn ($table): bool => in_array($table, $this->getExcludedTables())) + ->values() + ->toArray(); + } + + /** + * Get tables that should be excluded from migration + * + * @return array + */ + private function getExcludedTables(): array + { + return [ + 'migrations', + 'password_resets', + 'password_reset_tokens', + 'personal_access_tokens', + 'failed_jobs', + 'jobs', + 'job_batches', + ]; + } + + /** + * Get the number of records in a table + */ + public function getTableRecordCount(string $table): int + { + return DB::connection($this->sourceConnection) + ->table($table) + ->count(); + } + + /** + * Migrate a single table from source to target + */ + public function migrateTable(string $table, int $chunkSize = 1000, ?callable $progressCallback = null): void + { + // First, ensure the table exists in target database + if (! Schema::connection($this->targetConnection)->hasTable($table)) { + throw new \Exception("Table '{$table}' does not exist in target database. Run migrations first."); + } + + // Clear existing data in target table + DB::connection($this->targetConnection)->table($table)->truncate(); + + $totalRecords = $this->getTableRecordCount($table); + $processedRecords = 0; + + // Process data in chunks + DB::connection($this->sourceConnection) + ->table($table) + ->orderBy('id') + ->chunk($chunkSize, function (Collection $records) use ( + $table, + &$processedRecords, + $totalRecords, + $progressCallback + ): void { + $data = $records->map(fn ($record): array => $this->transformRecord((array) $record))->toArray(); + + // Insert into target database + DB::connection($this->targetConnection) + ->table($table) + ->insert($data); + + $processedRecords += count($records); + + if ($progressCallback) { + $progressCallback($processedRecords, $totalRecords); + } + }); + } + + /** + * Transform a record for PostgreSQL compatibility + * + * @param array $record + * @return array + */ + private function transformRecord(array $record): array + { + foreach ($record as $key => $value) { + // Handle MySQL boolean fields (tinyint) to PostgreSQL boolean + if (is_int($value) && in_array($value, [0, 1]) && preg_match('/^(is_|has_|can_|should_|enabled|active|published|verified)/', $key)) { + $record[$key] = (bool) $value; + } + + // Handle empty strings that should be null in PostgreSQL + if ($value === '') { + $record[$key] = null; + } + + // Handle MySQL timestamp '0000-00-00 00:00:00' to null + if ($value === '0000-00-00 00:00:00') { + $record[$key] = null; + } + } + + return $record; + } + + /** + * Verify migration by comparing record counts + * + * @return array + */ + public function verifyMigration(array $tables): array + { + $results = []; + + foreach ($tables as $table) { + $sourceCount = $this->getTableRecordCount($table); + $targetCount = DB::connection($this->targetConnection) + ->table($table) + ->count(); + + $results[$table] = [ + 'source' => $sourceCount, + 'target' => $targetCount, + 'match' => $sourceCount === $targetCount, + ]; + } + + return $results; + } + + /** + * Test database connections + */ + public function testConnections(): array + { + $results = []; + + try { + DB::connection($this->sourceConnection)->getPdo(); + $results['source'] = true; + } catch (\Exception $e) { + $results['source'] = false; + $results['source_error'] = $e->getMessage(); + } + + try { + DB::connection($this->targetConnection)->getPdo(); + $results['target'] = true; + } catch (\Exception $e) { + $results['target'] = false; + $results['target_error'] = $e->getMessage(); + } + + return $results; + } +} diff --git a/app-modules/database-migration/src/Services/SshTunnelService.php b/app-modules/database-migration/src/Services/SshTunnelService.php new file mode 100644 index 00000000..ad617027 --- /dev/null +++ b/app-modules/database-migration/src/Services/SshTunnelService.php @@ -0,0 +1,152 @@ + */ + private array $output = []; + + public function __construct() + { + $this->buildCommands(); + } + + public function activate(): int + { + if ($this->isActive()) { + $this->log('SSH tunnel is already active'); + + return 1; + } + + $this->createTunnel(); + + $maxTries = config('ssh-tunnel.connection.max_tries', 3); + for ($i = 0; $i < $maxTries; $i++) { + if ($this->isActive()) { + $this->log('SSH tunnel activated successfully'); + + return 2; + } + + usleep(config('ssh-tunnel.connection.wait_microseconds', 1000000)); + } + + throw new SshTunnelException( + sprintf( + "Could not create SSH tunnel with command:\n\t%s\nCheck your configuration.", + $this->sshCommand + ) + ); + } + + public function isActive(): bool + { + $verifyProcess = config('ssh-tunnel.verify_process', 'nc'); + + return match ($verifyProcess) { + 'bash' => $this->runCommand($this->bashCommand), + default => $this->runCommand($this->ncCommand), + }; + } + + public function destroy(): bool + { + $sshCommand = preg_replace('/[\s]{2}[\s]*/', ' ', $this->sshCommand); + $killCommand = sprintf('pkill -f "%s"', $sshCommand); + + $result = $this->runCommand($killCommand); + + if ($result) { + $this->log('SSH tunnel destroyed successfully'); + } + + return $result; + } + + private function createTunnel(): void + { + $nohupPath = config('ssh-tunnel.executables.nohup'); + $nohupLog = config('ssh-tunnel.logging.nohup_log', '/dev/null'); + + $command = sprintf( + '%s %s >> %s 2>&1 &', + $nohupPath, + $this->sshCommand, + $nohupLog + ); + + $this->runCommand($command); + + // Wait for connection to establish + usleep(config('ssh-tunnel.connection.wait_microseconds', 1000000)); + + $this->log('SSH tunnel creation command executed', ['command' => $command]); + } + + private function buildCommands(): void + { + $config = config('ssh-tunnel'); + + // Build netcat verification command + $this->ncCommand = sprintf( + '%s -vz %s %d > /dev/null 2>&1', + $config['executables']['nc'], + $config['local']['address'], + $config['local']['port'] + ); + + // Build bash verification command + $this->bashCommand = sprintf( + 'timeout 1 %s -c \'cat < /dev/null > /dev/tcp/%s/%d\' > /dev/null 2>&1', + $config['executables']['bash'], + $config['local']['address'], + $config['local']['port'] + ); + + // Build SSH tunnel command + $this->sshCommand = sprintf( + '%s %s %s -N -i %s -L %d:%s:%d -p %d %s@%s', + $config['executables']['ssh'], + $config['ssh']['options'], + $config['ssh']['verbosity'], + $config['ssh']['identity_file'], + $config['local']['port'], + $config['remote']['bind_address'], + $config['remote']['bind_port'], + $config['ssh']['port'], + $config['ssh']['user'], + $config['ssh']['hostname'] + ); + } + + private function runCommand(string $command): bool + { + $returnVar = 1; + exec($command, $this->output, $returnVar); + + return $returnVar === 0; + } + + private function log(string $message, array $context = []): void + { + if (! config('ssh-tunnel.logging.enabled', true)) { + return; + } + + $channel = config('ssh-tunnel.logging.channel', 'single'); + Log::channel($channel)->info('[SSH Tunnel] '.$message, $context); + } +} diff --git a/app-modules/database-migration/tests/Feature/MigrateDatabaseCommandTest.php b/app-modules/database-migration/tests/Feature/MigrateDatabaseCommandTest.php new file mode 100644 index 00000000..9eb2a127 --- /dev/null +++ b/app-modules/database-migration/tests/Feature/MigrateDatabaseCommandTest.php @@ -0,0 +1,90 @@ +artisan('db:migrate-mysql-to-pgsql --help') + ->assertExitCode(0); +}); + +it('can run dry run migration', function (): void { + // Mock the services + $mockTunnelService = $this->mock(SshTunnelService::class); + $mockTunnelService->shouldReceive('isActive')->andReturn(true); + + $mockMigrationService = $this->mock(DatabaseMigrationService::class); + $mockMigrationService->shouldReceive('getSourceTables') + ->andReturn(['users', 'articles']); + $mockMigrationService->shouldReceive('getTableRecordCount') + ->with('users') + ->andReturn(100); + $mockMigrationService->shouldReceive('getTableRecordCount') + ->with('articles') + ->andReturn(50); + + $this->app->instance(SshTunnelService::class, $mockTunnelService); + $this->app->instance(DatabaseMigrationService::class, $mockMigrationService); + + $this->artisan('db:migrate-mysql-to-pgsql --dry-run') + ->expectsOutput('🚀 Starting MySQL to PostgreSQL migration...') + ->expectsOutput('✅ SSH tunnel is active') + ->expectsOutput('🔍 DRY RUN MODE - No data will be actually migrated') + ->expectsOutput('📋 Found 2 tables to migrate') + ->assertExitCode(0); +}); + +it('activates ssh tunnel if not active', function (): void { + // Mock the services + $mockTunnelService = $this->mock(SshTunnelService::class); + $mockTunnelService->shouldReceive('isActive')->andReturn(false, true); + $mockTunnelService->shouldReceive('activate')->once(); + + $mockMigrationService = $this->mock(DatabaseMigrationService::class); + $mockMigrationService->shouldReceive('getSourceTables') + ->andReturn(['users']); + $mockMigrationService->shouldReceive('getTableRecordCount') + ->with('users') + ->andReturn(10); + + $this->app->instance(SshTunnelService::class, $mockTunnelService); + $this->app->instance(DatabaseMigrationService::class, $mockMigrationService); + + $this->artisan('db:migrate-mysql-to-pgsql --dry-run') + ->expectsOutput('SSH tunnel is not active. Attempting to activate...') + ->expectsOutput('✅ SSH tunnel is active') + ->assertExitCode(0); +}); + +it('fails when ssh tunnel cannot be activated', function (): void { + // Mock the services + $mockTunnelService = $this->mock(SshTunnelService::class); + $mockTunnelService->shouldReceive('isActive')->andReturn(false); + $mockTunnelService->shouldReceive('activate')->once(); + + $this->app->instance(SshTunnelService::class, $mockTunnelService); + + $this->artisan('db:migrate-mysql-to-pgsql --dry-run') + ->expectsOutput('❌ Failed to activate SSH tunnel. Migration aborted.') + ->assertExitCode(1); +}); + +it('can migrate specific tables', function (): void { + // Mock the services + $mockTunnelService = $this->mock(SshTunnelService::class); + $mockTunnelService->shouldReceive('isActive')->andReturn(true); + + $mockMigrationService = $this->mock(DatabaseMigrationService::class); + $mockMigrationService->shouldReceive('getTableRecordCount') + ->with('users') + ->andReturn(100); + + $this->app->instance(SshTunnelService::class, $mockTunnelService); + $this->app->instance(DatabaseMigrationService::class, $mockMigrationService); + + $this->artisan('db:migrate-mysql-to-pgsql --tables=users --dry-run') + ->expectsOutput('📋 Found 1 tables to migrate') + ->assertExitCode(0); +}); diff --git a/app-modules/database-migration/tests/Feature/SshTunnelCommandTest.php b/app-modules/database-migration/tests/Feature/SshTunnelCommandTest.php new file mode 100644 index 00000000..3ba22f60 --- /dev/null +++ b/app-modules/database-migration/tests/Feature/SshTunnelCommandTest.php @@ -0,0 +1,36 @@ +artisan('ssh-tunnel:activate --help') + ->assertExitCode(0); +}); + +it('can check ssh tunnel status', function (): void { + // Mock the service using a spy to avoid final class issues + $mockService = $this->spy(SshTunnelService::class); + $mockService->shouldReceive('isActive')->andReturn(false); + + $this->app->instance(SshTunnelService::class, $mockService); + + $this->artisan('ssh-tunnel:activate --check') + ->expectsOutput('🔍 Checking SSH tunnel status...') + ->expectsOutput('❌ SSH tunnel is not active') + ->assertExitCode(1); +}); + +it('can destroy ssh tunnel', function (): void { + // Mock the service using a spy to avoid final class issues + $mockService = $this->spy(SshTunnelService::class); + $mockService->shouldReceive('destroy')->andReturn(true); + + $this->app->instance(SshTunnelService::class, $mockService); + + $this->artisan('ssh-tunnel:activate --destroy') + ->expectsOutput('🔥 Destroying SSH tunnel...') + ->expectsOutput('✅ SSH tunnel destroyed successfully') + ->assertExitCode(0); +}); diff --git a/app-modules/database-migration/tests/Unit/DatabaseMigrationServiceTest.php b/app-modules/database-migration/tests/Unit/DatabaseMigrationServiceTest.php new file mode 100644 index 00000000..ca881fc2 --- /dev/null +++ b/app-modules/database-migration/tests/Unit/DatabaseMigrationServiceTest.php @@ -0,0 +1,72 @@ +service = new DatabaseMigrationService; +}); + +it('can be instantiated', function (): void { + expect($this->service)->toBeInstanceOf(DatabaseMigrationService::class); +}); + +it('can test database connections', function (): void { + $mockService = $this->mock(DatabaseMigrationService::class); + $mockService->shouldReceive('testConnections') + ->once() + ->andReturn([ + 'source' => true, + 'target' => true, + ]); + + $result = $mockService->testConnections(); + + expect($result)->toHaveKey('source') + ->and($result)->toHaveKey('target') + ->and($result['source'])->toBeTrue() + ->and($result['target'])->toBeTrue(); +}); + +it('can get source tables', function (): void { + $mockService = $this->mock(DatabaseMigrationService::class); + $mockService->shouldReceive('getSourceTables') + ->once() + ->andReturn(['users', 'articles', 'discussions']); + + $tables = $mockService->getSourceTables(); + + expect($tables)->toBeArray() + ->and($tables)->toContain('users', 'articles', 'discussions'); +}); + +it('can get table record count', function (): void { + $mockService = $this->mock(DatabaseMigrationService::class); + $mockService->shouldReceive('getTableRecordCount') + ->with('users') + ->once() + ->andReturn(100); + + $count = $mockService->getTableRecordCount('users'); + + expect($count)->toBe(100); +}); + +it('can verify migration results', function (): void { + $mockService = $this->mock(DatabaseMigrationService::class); + $mockService->shouldReceive('verifyMigration') + ->with(['users', 'articles']) + ->once() + ->andReturn([ + 'users' => ['source' => 100, 'target' => 100, 'match' => true], + 'articles' => ['source' => 50, 'target' => 50, 'match' => true], + ]); + + $result = $mockService->verifyMigration(['users', 'articles']); + + expect($result)->toHaveKey('users') + ->and($result)->toHaveKey('articles') + ->and($result['users']['match'])->toBeTrue() + ->and($result['articles']['match'])->toBeTrue(); +}); diff --git a/app-modules/database-migration/tests/Unit/SshTunnelServiceTest.php b/app-modules/database-migration/tests/Unit/SshTunnelServiceTest.php new file mode 100644 index 00000000..5f5d1a8f --- /dev/null +++ b/app-modules/database-migration/tests/Unit/SshTunnelServiceTest.php @@ -0,0 +1,52 @@ + 'testuser', + 'ssh-tunnel.ssh.hostname' => 'test.example.com', + 'ssh-tunnel.ssh.identity_file' => '/path/to/key', + 'ssh-tunnel.ssh.local_port' => 3307, + 'ssh-tunnel.ssh.bind_port' => 3306, + 'ssh-tunnel.ssh.bind_address' => '127.0.0.1', + 'ssh-tunnel.executables.ssh' => '/usr/bin/ssh', + 'ssh-tunnel.executables.ps' => '/bin/ps', + 'ssh-tunnel.executables.grep' => '/usr/bin/grep', + 'ssh-tunnel.executables.awk' => '/usr/bin/awk', + 'ssh-tunnel.connection.max_tries' => 1, // Reduce retries for faster testing + 'ssh-tunnel.connection.wait_microseconds' => 100000, // Reduce wait time + 'ssh-tunnel.logging.enabled' => false, + ]); + + $this->service = new SshTunnelService; +}); + +it('can be instantiated', function (): void { + expect($this->service)->toBeInstanceOf(SshTunnelService::class); +}); + +it('throws exception when tunnel creation fails', function (): void { + // Mock the service to simulate failure + $mockService = $this->mock(SshTunnelService::class); + $mockService->shouldReceive('activate') + ->once() + ->andThrow(new SshTunnelException('Could not create SSH tunnel')); + + expect(fn () => $mockService->activate()) + ->toThrow(SshTunnelException::class); +}); + +it('can check if tunnel is active', function (): void { + // Mock the service to return false for isActive + $mockService = $this->mock(SshTunnelService::class); + $mockService->shouldReceive('isActive') + ->once() + ->andReturn(false); + + expect($mockService->isActive())->toBeFalse(); +}); diff --git a/composer.json b/composer.json index 1f898644..35af5e85 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "laravel/octane": "^2.12", "laravel/socialite": "^5.6.3", "laravel/tinker": "^2.8.1", + "laravelcm/database-migration": "*", "laravelcm/gamify": "^1.1", "laravelcm/laravel-subscriptions": "^1.3", "laravelcm/livewire-slide-overs": "^1.0", diff --git a/composer.lock b/composer.lock index 913656fa..ec0c371a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "254eaff8463abafb7d59a0d7164d98b8", + "content-hash": "27dc1cc0157a668ee520e87bbf4c967d", "packages": [ { "name": "abraham/twitteroauth", @@ -5710,6 +5710,45 @@ }, "time": "2025-01-27T14:24:01+00:00" }, + { + "name": "laravelcm/database-migration", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "app-modules/database-migration", + "reference": "3cde3251cf41fd249a4005581fcdba9c0989ca9c" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "pestphp/pest": "^3.8", + "pestphp/pest-plugin-laravel": "^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravelcm\\DatabaseMigration\\Providers\\DatabaseMigrationServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravelcm\\DatabaseMigration\\": "src/", + "Laravelcm\\DatabaseMigration\\Database\\Factories\\": "database/factories/", + "Laravelcm\\DatabaseMigration\\Database\\Seeders\\": "database/seeders/" + } + }, + "license": [ + "proprietary" + ], + "description": "Module laravelcm/database-migration for Laravel.cm - Migration tools from MySQL to PostgreSQL with SSH tunnel support", + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "laravelcm/gamify", "version": "1.1.0", diff --git a/config/database.php b/config/database.php index 72faa7eb..6ba0a5d6 100644 --- a/config/database.php +++ b/config/database.php @@ -65,6 +65,27 @@ ]) : [], ], + 'secondary' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST_SECOND', '127.0.0.1'), + 'port' => env('DB_PORT_SECOND', '3306'), + 'database' => env('DB_DATABASE_SECOND', 'laravel'), + 'username' => env('DB_USERNAME_SECOND', 'root'), + 'password' => env('DB_PASSWORD_SECOND', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + PDO::ATTR_TIMEOUT => 5, // 5 secondes pour la connexion + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]) : [], + ], + 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), diff --git a/phpstan.neon b/phpstan.neon index d22df2f1..c5d206d0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,7 @@ parameters: - app/Models/Traits/HasSlug.php - app-modules/*/vendor/* - app-modules/*/tests/* + - app-modules/*/config/* ignoreErrors: - "#^Cannot access property \\$transaction on array\\|object\\.$#" - identifier: missingType.iterableValue diff --git a/pint.json b/pint.json index c5bc9d53..d8fc6374 100644 --- a/pint.json +++ b/pint.json @@ -4,7 +4,6 @@ "array_syntax": true, "declare_parentheses": true, "declare_strict_types": true, - "final_class": true, "blank_line_before_statement": { "statements": [ "break", From b0c709ef22ad760f1d2de8dde85e1c7c94983901 Mon Sep 17 00:00:00 2001 From: Arthur Monney Date: Mon, 15 Sep 2025 11:52:17 +0200 Subject: [PATCH 2/2] feat: Create s3 disk migrate command --- .../Commands/MigrateFilesToS3Command.php | 209 ++++++++++++++++++ config/filesystems.php | 15 ++ 2 files changed, 224 insertions(+) create mode 100644 app-modules/database-migration/src/Console/Commands/MigrateFilesToS3Command.php diff --git a/app-modules/database-migration/src/Console/Commands/MigrateFilesToS3Command.php b/app-modules/database-migration/src/Console/Commands/MigrateFilesToS3Command.php new file mode 100644 index 00000000..b8358f5c --- /dev/null +++ b/app-modules/database-migration/src/Console/Commands/MigrateFilesToS3Command.php @@ -0,0 +1,209 @@ +info('🚀 Starting file migration to S3...'); + + $targetDisk = (string) $this->option('target-disk'); + $isDryRun = $this->option('dry-run'); + /** @var int<1, max> $chunkSize */ + $chunkSize = max(1, (int) $this->option('chunk')); + + // Verify S3 disk configuration + if (! $this->verifyS3Configuration($targetDisk)) { + return Command::FAILURE; + } + + if ($isDryRun) { + $this->warn('🔍 DRY RUN MODE - No files will be actually migrated'); + } + + try { + $sourceDirs = $this->getSourceDirectories(); + + foreach ($sourceDirs as $sourceDir) { + $this->info("📁 Processing directory: {$sourceDir['path']}"); + $this->migrateDirectory($sourceDir, $targetDisk, $chunkSize, $isDryRun); + } + + $this->displaySummary(); + + if ($isDryRun) { + $this->info('✅ Dry run completed successfully'); + } else { + $this->info('✅ File migration completed successfully'); + } + + return Command::SUCCESS; + + } catch (\Exception $e) { + $this->error("❌ Migration failed: {$e->getMessage()}"); + + return Command::FAILURE; + } + } + + private function verifyS3Configuration(string $disk): bool + { + try { + $config = config("filesystems.disks.{$disk}"); + + if (! $config) { + $this->error("❌ Disk '{$disk}' not found in configuration"); + + return false; + } + + if ($config['driver'] !== 's3') { + $this->error("❌ Disk '{$disk}' is not an S3 disk"); + + return false; + } + + // Test S3 connection + Storage::disk($disk)->files(''); + $this->info("✅ S3 disk '{$disk}' is properly configured"); + + return true; + + } catch (\Exception $e) { + $this->error("❌ S3 configuration error: {$e->getMessage()}"); + + return false; + } + } + + private function getSourceDirectories(): array + { + return [ + [ + 'path' => public_path('media'), + 's3_prefix' => '', + 'name' => 'Media Library (public/media)', + ], + [ + 'path' => storage_path('app/public'), + 's3_prefix' => '', + 'name' => 'Public Storage (storage/app/public)', + ], + ]; + } + + private function migrateDirectory(array $sourceDir, string $targetDisk, int $chunkSize, bool $isDryRun): void + { + if (! File::exists($sourceDir['path'])) { + $this->warn("⚠️ Directory does not exist: {$sourceDir['path']}"); + + return; + } + + $finder = new Finder; + $files = $finder->files()->in($sourceDir['path']); + + $fileList = iterator_to_array($files); + $totalFiles = count($fileList); + $this->totalFiles += $totalFiles; + + if ($totalFiles === 0) { + $this->info("📂 No files found in {$sourceDir['name']}"); + + return; + } + + $this->info("📊 Found {$totalFiles} files in {$sourceDir['name']}"); + + $progressBar = $this->output->createProgressBar($totalFiles); + $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%'); + $progressBar->start(); + + // @phpstan-ignore-next-line + $chunks = array_chunk($fileList, $chunkSize); + + foreach ($chunks as $chunk) { + foreach ($chunk as $file) { + $this->migrateFile($file, $sourceDir, $targetDisk, $isDryRun); + $progressBar->advance(); + } + } + + $progressBar->finish(); + $this->newLine(); + } + + private function migrateFile(\SplFileInfo $file, array $sourceDir, string $targetDisk, bool $isDryRun): void + { + try { + $relativePath = str_replace($sourceDir['path'].'/', '', $file->getPathname()); + + // Get the root directory from S3 disk configuration + $diskConfig = config("filesystems.disks.{$targetDisk}"); + $rootDir = $diskConfig['root'] ?? 'public'; + + // Build S3 path: root/relativePath (without additional prefix) + $s3Path = $rootDir.'/'.$relativePath; + + if ($isDryRun) { + $this->processedFiles++; + + return; + } + + // Upload to S3 + $fileContent = File::get($file->getPathname()); + $uploaded = Storage::disk($targetDisk)->put($s3Path, $fileContent, 'public'); + + if ($uploaded) { + $this->processedFiles++; + } else { + $this->failedFiles++; + } + + } catch (\Exception $e) { + $this->failedFiles++; + $this->line(" ❌ Error migrating {$file->getFilename()}: {$e->getMessage()}"); + } + } + + private function displaySummary(): void + { + $this->newLine(); + $this->info('📊 Migration Summary:'); + $this->table( + ['Metric', 'Count'], + [ + ['Total files found', $this->totalFiles], + ['Successfully processed', $this->processedFiles], + ['Failed', $this->failedFiles], + ['Success rate', $this->totalFiles > 0 ? round(($this->processedFiles / $this->totalFiles) * 100, 2).'%' : '0%'], + ] + ); + + if ($this->failedFiles > 0) { + $this->warn("⚠️ {$this->failedFiles} files failed to migrate. Check the logs above for details."); + } + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 7b1617f1..f30947d1 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -57,6 +57,21 @@ 'throw' => false, ], + 'media-s3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'root' => env('AWS_ROOT', 'public'), + 'visibility' => 'public', + 'directory_visibility' => 'public', + 'throw' => false, + ], + 'media' => [ 'driver' => 'local', 'root' => public_path('media'),