diff --git a/Modules/Helpdesk/Policies/TicketPolicy.php b/Modules/Helpdesk/Policies/TicketPolicy.php index 3e0f6832..f1bbcb5f 100644 --- a/Modules/Helpdesk/Policies/TicketPolicy.php +++ b/Modules/Helpdesk/Policies/TicketPolicy.php @@ -9,8 +9,10 @@ use Modules\Helpdesk\Models\Ticket; final class TicketPolicy { - public function viewAny(User $_user): bool + public function viewAny(User $user): bool { + unset($user); + return true; } @@ -21,8 +23,10 @@ final class TicketPolicy || (int) $ticket->user_id === (int) $user->id; } - public function create(User $_user): bool + public function create(User $user): bool { + unset($user); + return true; } diff --git a/app/Console/Commands/DatabaseStage.php b/app/Console/Commands/DatabaseStage.php index f8e9ffc5..2db8b5eb 100644 --- a/app/Console/Commands/DatabaseStage.php +++ b/app/Console/Commands/DatabaseStage.php @@ -16,391 +16,275 @@ class DatabaseStage extends Command public function handle(): int { - $local = [ - 'host' => config('database.connections.pgsql.host'), - 'port' => config('database.connections.pgsql.port'), - 'database' => config('database.connections.pgsql.database'), - 'username' => config('database.connections.pgsql.username'), - 'password' => config('database.connections.pgsql.password'), - ]; + $local = $this->localConfiguration(); + $stage = $this->stageConfiguration(); - $stage = [ - 'ssh_host' => env('STAGE_SSH_HOST'), - 'ssh_user' => env('STAGE_SSH_USER'), - 'ssh_port' => env('STAGE_SSH_PORT', 22), - - 'db_host' => env('STAGE_DB_HOST', '127.0.0.1'), - 'db_port' => env('STAGE_DB_PORT', 5432), - 'database' => env('STAGE_DB_DATABASE'), - 'username' => env('STAGE_DB_USERNAME'), - 'password' => env('STAGE_DB_PASSWORD'), - - // PostgreSQL administrator used to recreate the stage DB. - 'admin_username' => env('STAGE_DB_ADMIN_USERNAME', 'postgres'), - ]; - - foreach ([ - 'STAGE_SSH_HOST' => $stage['ssh_host'], - 'STAGE_SSH_USER' => $stage['ssh_user'], - 'STAGE_DB_DATABASE' => $stage['database'], - 'STAGE_DB_USERNAME' => $stage['username'], - ] as $name => $value) { - if (! $value) { - $this->error("Missing {$name}."); - - return self::FAILURE; - } + if (! $this->validateStageConfiguration($stage)) { + return self::FAILURE; } - $timestamp = now()->format('Ymd-His'); - $filename = "wms-{$timestamp}.dump"; + [$localDump, $remoteDump] = $this->dumpPaths(); + $this->displayCloneSummary($local, $stage); - $localDump = storage_path("app/{$filename}"); - $remoteDump = "/tmp/{$filename}"; - - $this->newLine(); - $this->components->info('WMS PostgreSQL FULL CLONE → Stage'); - $this->newLine(); - - $this->table( - ['Setting', 'Value'], - [ - ['Local database', $local['database']], - ['Stage server', "{$stage['ssh_user']}@{$stage['ssh_host']}"], - ['Stage database', $stage['database']], - ['Stage owner', $stage['username']], - ], - ); - - if ( - ! $this->option('force') && - ! $this->confirm( - "This will DROP and completely RECREATE '{$stage['database']}'. Continue?", - false, - ) - ) { + if (! $this->confirmed($stage)) { $this->warn('Cancelled.'); return self::SUCCESS; } try { - /* - * --------------------------------------------------------- - * Ensure local dump directory exists - * --------------------------------------------------------- - */ - - $directory = dirname($localDump); - - if (! is_dir($directory)) { - mkdir($directory, 0755, true); - } - - /* - * --------------------------------------------------------- - * Dump COMPLETE local database - * --------------------------------------------------------- - */ - - $this->components->task( - 'Backing up complete local PostgreSQL database', - function () use ($local, $localDump) { - $result = Process::env([ - 'PGPASSWORD' => $local['password'], - ])->timeout(3600)->run([ - 'pg_dump', - '--host=' . $local['host'], - '--port=' . $local['port'], - '--username=' . $local['username'], - - // Custom archive format. - '--format=custom', - - // Maximum compression. - '--compress=9', - - // Stage user becomes owner. - '--no-owner', - - // Do not copy local ACLs/grants. - '--no-acl', - - // Include everything in the database. - '--file=' . $localDump, - - $local['database'], - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "pg_dump failed:\n" . $result->errorOutput(), - ); - } - - if (! file_exists($localDump) || filesize($localDump) === 0) { - throw new DatabaseStageException('Database dump was not created or is empty.'); - } - - return true; - }, - ); - - $dumpSize = $this->formatBytes(filesize($localDump)); - - $this->line(" Dump size: {$dumpSize}"); - - /* - * --------------------------------------------------------- - * Inspect dump - * --------------------------------------------------------- - */ - - $this->components->task( - 'Verifying PostgreSQL archive', - function () use ($localDump) { - $result = Process::run([ - 'pg_restore', - '--list', - $localDump, - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "Invalid PostgreSQL archive:\n" . $result->errorOutput(), - ); - } - - return true; - }, - ); - - /* - * --------------------------------------------------------- - * Upload - * --------------------------------------------------------- - */ - - $this->components->task( - 'Uploading database backup', - function () use ($stage, $localDump, $remoteDump) { - $result = Process::timeout(3600)->run([ - 'scp', - '-P', - (string) $stage['ssh_port'], - $localDump, - "{$stage['ssh_user']}@{$stage['ssh_host']}:{$remoteDump}", - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "Upload failed:\n" . $result->errorOutput(), - ); - } - - return true; - }, - ); - - /* - * --------------------------------------------------------- - * DROP + recreate stage database - * - * Pegasus permits the SSH user to run PostgreSQL commands - * through sudo -u postgres. - * --------------------------------------------------------- - */ - - $this->components->task( - 'Recreating stage database', - function () use ($stage) { - $database = $this->quoteSqlIdentifier($stage['database']); - $owner = $this->quoteSqlIdentifier($stage['username']); - - $terminateDatabase = str_replace( - "'", - "''", - $stage['database'], - ); - - $sql = << pg_backend_pid(); - -DROP DATABASE IF EXISTS {$database}; -CREATE DATABASE {$database} - WITH OWNER {$owner} - ENCODING 'UTF8' - TEMPLATE template0; -SQL; - - $remoteCommand = sprintf( - 'sudo -u %s psql --set ON_ERROR_STOP=on --dbname=postgres', - escapeshellarg($stage['admin_username']), - ); - - $result = Process::input($sql) - ->timeout(300) - ->run([ - 'ssh', - '-p', - (string) $stage['ssh_port'], - "{$stage['ssh_user']}@{$stage['ssh_host']}", - $remoteCommand, - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "Unable to recreate stage database:\n" . - $result->errorOutput(), - ); - } - - return true; - }, - ); - - /* - * --------------------------------------------------------- - * Restore COMPLETE archive - * --------------------------------------------------------- - */ - - $this->components->task( - 'Restoring complete stage database', - function () use ($stage, $remoteDump) { - $restore = sprintf( - 'PGPASSWORD=%s pg_restore ' . - '--host=%s ' . - '--port=%s ' . - '--username=%s ' . - '--dbname=%s ' . - '--no-owner ' . - '--no-acl ' . - '--exit-on-error ' . - '--verbose ' . - '%s', - escapeshellarg($stage['password']), - escapeshellarg($stage['db_host']), - escapeshellarg((string) $stage['db_port']), - escapeshellarg($stage['username']), - escapeshellarg($stage['database']), - escapeshellarg($remoteDump), - ); - - $result = Process::timeout(3600)->run([ - 'ssh', - '-p', - (string) $stage['ssh_port'], - "{$stage['ssh_user']}@{$stage['ssh_host']}", - $restore, - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "Database restore failed:\n" . - $result->errorOutput(), - ); - } - - return true; - }, - ); - - /* - * --------------------------------------------------------- - * Verify stage tables - * --------------------------------------------------------- - */ - - $this->components->task( - 'Verifying restored database', - function () use ($stage) { - $command = sprintf( - 'PGPASSWORD=%s psql ' . - '--host=%s ' . - '--port=%s ' . - '--username=%s ' . - '--dbname=%s ' . - '--tuples-only ' . - '--command=%s', - escapeshellarg($stage['password']), - escapeshellarg($stage['db_host']), - escapeshellarg((string) $stage['db_port']), - escapeshellarg($stage['username']), - escapeshellarg($stage['database']), - escapeshellarg( - "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';", - ), - ); - - $result = Process::run([ - 'ssh', - '-p', - (string) $stage['ssh_port'], - "{$stage['ssh_user']}@{$stage['ssh_host']}", - $command, - ]); - - if ($result->failed()) { - throw new DatabaseStageException( - "Database verification failed:\n" . - $result->errorOutput(), - ); - } - - $count = trim($result->output()); - - $this->line(" Stage public tables: {$count}"); - - return true; - }, - ); - - /* - * --------------------------------------------------------- - * Cleanup - * --------------------------------------------------------- - */ - - Process::run([ - 'ssh', - '-p', - (string) $stage['ssh_port'], - "{$stage['ssh_user']}@{$stage['ssh_host']}", - 'rm', - '-f', - $remoteDump, - ]); - - if (! $this->option('keep')) { - @unlink($localDump); - } - - $this->newLine(); - $this->components->info( - 'Stage PostgreSQL database cloned successfully.', - ); - - $this->line( - "Local {$local['database']} → Stage {$stage['database']}", - ); - - return self::SUCCESS; - } catch (\Throwable $e) { - $this->newLine(); - $this->components->error('Database clone failed.'); - $this->error($e->getMessage()); - - $this->newLine(); - $this->warn( - "The dump may still exist at {$localDump} and/or {$remoteDump}.", - ); + $this->runClone($local, $stage, $localDump, $remoteDump); + } catch (\Throwable $exception) { + $this->reportFailure($exception, $localDump, $remoteDump); return self::FAILURE; } + + $this->reportSuccess($local, $stage); + + return self::SUCCESS; + } + + /** @return array */ + private function localConfiguration(): array + { + return [ + 'host' => config('database.connections.pgsql.host'), + 'port' => config('database.connections.pgsql.port'), + 'database' => config('database.connections.pgsql.database'), + 'username' => config('database.connections.pgsql.username'), + 'password' => config('database.connections.pgsql.password'), + ]; + } + + /** @return array */ + private function stageConfiguration(): array + { + return [ + 'ssh_host' => env('STAGE_SSH_HOST'), + 'ssh_user' => env('STAGE_SSH_USER'), + 'ssh_port' => env('STAGE_SSH_PORT', 22), + 'db_host' => env('STAGE_DB_HOST', '127.0.0.1'), + 'db_port' => env('STAGE_DB_PORT', 5432), + 'database' => env('STAGE_DB_DATABASE'), + 'username' => env('STAGE_DB_USERNAME'), + 'password' => env('STAGE_DB_PASSWORD'), + 'admin_username' => env('STAGE_DB_ADMIN_USERNAME', 'postgres'), + ]; + } + + /** @param array $stage */ + private function validateStageConfiguration(array $stage): bool + { + $required = [ + 'STAGE_SSH_HOST' => $stage['ssh_host'], + 'STAGE_SSH_USER' => $stage['ssh_user'], + 'STAGE_DB_DATABASE' => $stage['database'], + 'STAGE_DB_USERNAME' => $stage['username'], + ]; + + foreach ($required as $name => $value) { + if (! $value) { + $this->error("Missing {$name}."); + + return false; + } + } + + return true; + } + + /** @return array{string, string} */ + private function dumpPaths(): array + { + $filename = 'wms-' . now()->format('Ymd-His') . '.dump'; + + return [storage_path("app/{$filename}"), "/tmp/{$filename}"]; + } + + /** @param array $local @param array $stage */ + private function displayCloneSummary(array $local, array $stage): void + { + $this->newLine(); + $this->components->info('WMS PostgreSQL FULL CLONE → Stage'); + $this->newLine(); + $this->table(['Setting', 'Value'], [ + ['Local database', $local['database']], + ['Stage server', "{$stage['ssh_user']}@{$stage['ssh_host']}"], + ['Stage database', $stage['database']], + ['Stage owner', $stage['username']], + ]); + } + + /** @param array $stage */ + private function confirmed(array $stage): bool + { + return $this->option('force') || $this->confirm( + "This will DROP and completely RECREATE '{$stage['database']}'. Continue?", + false, + ); + } + + /** @param array $local @param array $stage */ + private function runClone(array $local, array $stage, string $localDump, string $remoteDump): void + { + $this->ensureDumpDirectory($localDump); + $this->createDump($local, $localDump); + $this->line(' Dump size: ' . $this->formatBytes((int) filesize($localDump))); + $this->verifyDump($localDump); + $this->uploadDump($stage, $localDump, $remoteDump); + $this->recreateStageDatabase($stage); + $this->restoreStageDatabase($stage, $remoteDump); + $this->verifyStageDatabase($stage); + $this->cleanupDumps($stage, $localDump, $remoteDump); + } + + private function ensureDumpDirectory(string $localDump): void + { + $directory = dirname($localDump); + + if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { + throw new DatabaseStageException("Unable to create dump directory: {$directory}"); + } + } + + /** @param array $local */ + private function createDump(array $local, string $localDump): void + { + $this->components->task('Backing up complete local PostgreSQL database', function () use ($local, $localDump): bool { + $result = Process::env(['PGPASSWORD' => $local['password']])->timeout(3600)->run([ + 'pg_dump', '--host=' . $local['host'], '--port=' . $local['port'], + '--username=' . $local['username'], '--format=custom', '--compress=9', + '--no-owner', '--no-acl', '--file=' . $localDump, $local['database'], + ]); + $this->throwWhenFailed($result->failed(), "pg_dump failed:\n" . $result->errorOutput()); + + if (! file_exists($localDump) || filesize($localDump) === 0) { + throw new DatabaseStageException('Database dump was not created or is empty.'); + } + + return true; + }); + } + + private function verifyDump(string $localDump): void + { + $this->components->task('Verifying PostgreSQL archive', function () use ($localDump): bool { + $result = Process::run(['pg_restore', '--list', $localDump]); + $this->throwWhenFailed($result->failed(), "Invalid PostgreSQL archive:\n" . $result->errorOutput()); + + return true; + }); + } + + /** @param array $stage */ + private function uploadDump(array $stage, string $localDump, string $remoteDump): void + { + $this->components->task('Uploading database backup', function () use ($stage, $localDump, $remoteDump): bool { + $result = Process::timeout(3600)->run([ + 'scp', '-P', (string) $stage['ssh_port'], $localDump, + "{$stage['ssh_user']}@{$stage['ssh_host']}:{$remoteDump}", + ]); + $this->throwWhenFailed($result->failed(), "Upload failed:\n" . $result->errorOutput()); + + return true; + }); + } + + /** @param array $stage */ + private function recreateStageDatabase(array $stage): void + { + $this->components->task('Recreating stage database', function () use ($stage): bool { + $database = $this->quoteSqlIdentifier((string) $stage['database']); + $owner = $this->quoteSqlIdentifier((string) $stage['username']); + $terminateDatabase = str_replace("'", "''", (string) $stage['database']); + $sql = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{$terminateDatabase}' AND pid <> pg_backend_pid();\n" + . "DROP DATABASE IF EXISTS {$database};\nCREATE DATABASE {$database} WITH OWNER {$owner} ENCODING 'UTF8' TEMPLATE template0;"; + $remoteCommand = sprintf( + 'sudo -u %s psql --set ON_ERROR_STOP=on --dbname=postgres', + escapeshellarg((string) $stage['admin_username']), + ); + $result = Process::input($sql)->timeout(300)->run($this->sshCommand($stage, $remoteCommand)); + $this->throwWhenFailed($result->failed(), "Unable to recreate stage database:\n" . $result->errorOutput()); + + return true; + }); + } + + /** @param array $stage */ + private function restoreStageDatabase(array $stage, string $remoteDump): void + { + $this->components->task('Restoring complete stage database', function () use ($stage, $remoteDump): bool { + $restore = sprintf( + 'PGPASSWORD=%s pg_restore --host=%s --port=%s --username=%s --dbname=%s --no-owner --no-acl --exit-on-error --verbose %s', + escapeshellarg((string) $stage['password']), escapeshellarg((string) $stage['db_host']), + escapeshellarg((string) $stage['db_port']), escapeshellarg((string) $stage['username']), + escapeshellarg((string) $stage['database']), escapeshellarg($remoteDump), + ); + $result = Process::timeout(3600)->run($this->sshCommand($stage, $restore)); + $this->throwWhenFailed($result->failed(), "Database restore failed:\n" . $result->errorOutput()); + + return true; + }); + } + + /** @param array $stage */ + private function verifyStageDatabase(array $stage): void + { + $this->components->task('Verifying restored database', function () use ($stage): bool { + $command = sprintf( + 'PGPASSWORD=%s psql --host=%s --port=%s --username=%s --dbname=%s --tuples-only --command=%s', + escapeshellarg((string) $stage['password']), escapeshellarg((string) $stage['db_host']), + escapeshellarg((string) $stage['db_port']), escapeshellarg((string) $stage['username']), + escapeshellarg((string) $stage['database']), + escapeshellarg("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public';"), + ); + $result = Process::run($this->sshCommand($stage, $command)); + $this->throwWhenFailed($result->failed(), "Database verification failed:\n" . $result->errorOutput()); + $this->line(' Stage public tables: ' . trim($result->output())); + + return true; + }); + } + + /** @param array $stage */ + private function cleanupDumps(array $stage, string $localDump, string $remoteDump): void + { + Process::run($this->sshCommand($stage, 'rm -f ' . escapeshellarg($remoteDump))); + + if (! $this->option('keep')) { + @unlink($localDump); + } + } + + /** @param array $stage @return list */ + private function sshCommand(array $stage, string $command): array + { + return ['ssh', '-p', (string) $stage['ssh_port'], "{$stage['ssh_user']}@{$stage['ssh_host']}", $command]; + } + + private function throwWhenFailed(bool $failed, string $message): void + { + if ($failed) { + throw new DatabaseStageException($message); + } + } + + private function reportFailure(\Throwable $exception, string $localDump, string $remoteDump): void + { + $this->newLine(); + $this->components->error('Database clone failed.'); + $this->error($exception->getMessage()); + $this->newLine(); + $this->warn("The dump may still exist at {$localDump} and/or {$remoteDump}."); + } + + /** @param array $local @param array $stage */ + private function reportSuccess(array $local, array $stage): void + { + $this->newLine(); + $this->components->info('Stage PostgreSQL database cloned successfully.'); + $this->line("Local {$local['database']} → Stage {$stage['database']}"); } private function quoteSqlIdentifier(string $identifier): string