diff --git a/app/Console/Commands/UpDb.php b/app/Console/Commands/UpDb.php new file mode 100644 index 0000000..9429244 --- /dev/null +++ b/app/Console/Commands/UpDb.php @@ -0,0 +1,170 @@ +option('force') && ! $this->confirm('This will wipe your local database and replace it with production. Are you sure?')) { + $this->info('Aborted.'); + return self::SUCCESS; + } + + $local = $this->connectionConfig('local'); + $production = $this->connectionConfig('production'); + + $timestamp = now()->format('Y_m_d_His'); + $dumpPath = storage_path("app/private/db_dumps/production_{$timestamp}.dump"); + $backupPath = storage_path("app/private/db_dumps/local_backup_{$timestamp}.dump"); + + if (! is_dir(storage_path('app/private/db_dumps'))) { + mkdir(storage_path('app/private/db_dumps'), 0755, true); + } + + // Step 1: Dump production + $this->info('Dumping production database...'); + $dumpResult = $this->pgDump($production, $dumpPath); + + if ($dumpResult !== 0) { + $this->error('Failed to dump production database.'); + return self::FAILURE; + } + + $this->info("Production dump saved to: {$dumpPath}"); + + // Step 2: Back up local (unless skipped) + if (! $this->option('no-backup')) { + $this->info('Backing up local database...'); + $backupResult = $this->pgDump($local, $backupPath); + + if ($backupResult !== 0) { + $this->error('Failed to back up local database. Aborting to be safe. Use --no-backup to skip.'); + return self::FAILURE; + } + + $this->info("Local backup saved to: {$backupPath}"); + } + + // Step 3: Drop and recreate the local database + $this->info('Wiping local database...'); + $this->dropAndRecreateLocalDatabase($local); + + // Step 4: Restore production dump into local + $this->info('Restoring production dump to local database...'); + $restoreResult = $this->pgRestore($local, $dumpPath); + + if ($restoreResult !== 0) { + $this->error('Restore failed. Your local backup is at: ' . ($this->option('no-backup') ? 'N/A (backup was skipped)' : $backupPath)); + return self::FAILURE; + } + + $this->info('Done. Local database now mirrors production.'); + + return self::SUCCESS; + } + + private function connectionConfig(string $type): array + { + $connection = $type === 'production' ? 'pgsql_production' : config('database.default'); + + return [ + 'host' => config("database.connections.{$connection}.host"), + 'port' => config("database.connections.{$connection}.port"), + 'database' => config("database.connections.{$connection}.database"), + 'username' => config("database.connections.{$connection}.username"), + 'password' => config("database.connections.{$connection}.password"), + ]; + } + + private function pgDump(array $config, string $outputPath): int + { + $pgpass = $this->writePgPass($config); + + $command = sprintf( + 'pg_dump -Fc -h %s -p %s -U %s %s -f %s', + escapeshellarg($config['host']), + escapeshellarg($config['port']), + escapeshellarg($config['username']), + escapeshellarg($config['database']), + escapeshellarg($outputPath), + ); + + $resultCode = $this->runWithPgPass($pgpass, $command); + + unlink($pgpass); + + return $resultCode; + } + + private function pgRestore(array $config, string $dumpPath): int + { + $pgpass = $this->writePgPass($config); + + $command = sprintf( + 'pg_restore -h %s -p %s -U %s -d %s --no-owner --no-privileges %s', + escapeshellarg($config['host']), + escapeshellarg($config['port']), + escapeshellarg($config['username']), + escapeshellarg($config['database']), + escapeshellarg($dumpPath), + ); + + $resultCode = $this->runWithPgPass($pgpass, $command); + + unlink($pgpass); + + return $resultCode; + } + + /** + * Write a temporary pgpass file and return its path. + * This is the cross-platform alternative to PGPASSWORD which doesn't work on Windows. + */ + private function writePgPass(array $config): string + { + // Escape any colons or backslashes in the password per pgpass format rules + $password = str_replace(['\\', ':'], ['\\\\', '\\:'], $config['password']); + $content = "{$config['host']}:{$config['port']}:*:{$config['username']}:{$password}"; + $path = tempnam(sys_get_temp_dir(), 'pgpass_'); + + file_put_contents($path, $content); + chmod($path, 0600); + + return $path; + } + + private function runWithPgPass(string $pgpassFile, string $command): int + { + // PGPASSFILE is supported on all platforms including Windows + putenv("PGPASSFILE={$pgpassFile}"); + passthru($command, $resultCode); + putenv('PGPASSFILE'); + + return $resultCode; + } + + private function dropAndRecreateLocalDatabase(array $config): void + { + $database = $config['database']; + + // Connect to the postgres maintenance database to drop/recreate + $pdo = new \PDO( + "pgsql:host={$config['host']};port={$config['port']};dbname=postgres", + $config['username'], + $config['password'], + ); + + $pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = " . $pdo->quote($database)); + $pdo->exec("DROP DATABASE IF EXISTS " . '"' . $database . '"'); + $pdo->exec("CREATE DATABASE " . '"' . $database . '"'); + } +} diff --git a/composer.json b/composer.json index 6cdbad6..70b5b40 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", - "tightenco/ziggy": "^2.0" + "tightenco/ziggy": "^2.0", + "ext-pdo": "*" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/config/database.php b/config/database.php index 64709ce..50799b5 100644 --- a/config/database.php +++ b/config/database.php @@ -99,6 +99,18 @@ return [ 'sslmode' => env('DB_SSLMODE', 'prefer'), ], + 'pgsql_production' => [ + 'driver' => 'pgsql', + 'host' => env('PRODUCTION_DB_HOST', '127.0.0.1'), + 'port' => env('PRODUCTION_DB_PORT', '5432'), + 'database' => env('PRODUCTION_DB_DATABASE', ''), + 'username' => env('PRODUCTION_DB_USERNAME', ''), + 'password' => env('PRODUCTION_DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + ], + 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DB_URL'),