diff --git a/app/Console/Commands/DatabaseStage.php b/app/Console/Commands/DatabaseStage.php new file mode 100644 index 00000000..04aa2b9e --- /dev/null +++ b/app/Console/Commands/DatabaseStage.php @@ -0,0 +1,425 @@ + 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'), + ]; + + $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; + } + } + + $timestamp = now()->format('Ymd-His'); + $filename = "wms-{$timestamp}.dump"; + + $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 + ) + ) { + $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 RuntimeException( + "pg_dump failed:\n".$result->errorOutput() + ); + } + + if (! file_exists($localDump) || filesize($localDump) === 0) { + throw new RuntimeException('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 RuntimeException( + "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 RuntimeException( + "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 RuntimeException( + "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 RuntimeException( + "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 RuntimeException( + "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}." + ); + + return self::FAILURE; + } + } + + private function quoteSqlIdentifier(string $identifier): string + { + return '"'.str_replace('"', '""', $identifier).'"'; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + + $bytes = max($bytes, 0); + $power = $bytes > 0 + ? min((int) floor(log($bytes, 1024)), count($units) - 1) + : 0; + + return number_format( + $bytes / (1024 ** $power), + $power === 0 ? 0 : 2 + ).' '.$units[$power]; + } +} diff --git a/composer.json b/composer.json index ce856f37..87621a82 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.3", "ayeo/gs1_barcode": "^1.0", "barryvdh/laravel-dompdf": "^3.1", + "deployer/deployer": "^8.0", "filament/filament": "^5.6", "filament/tables": "^5.6", "guzzlehttp/guzzle": "^7.11", diff --git a/deploy.php b/deploy.php new file mode 100644 index 00000000..9dce2817 --- /dev/null +++ b/deploy.php @@ -0,0 +1,99 @@ +set('hostname', 'helpdesk.absdev.net') + ->set('remote_user', 'mag') + ->set('branch', 'main') + ->set('deploy_path', '/var/www/helpdesk'); + +host('develop') + ->set('hostname', 'helpdesk.mlba.dev') + ->set('remote_user', 'mag') + ->set('branch', 'develop') + ->set('deploy_path', '/var/www/helpdesk_dev'); + +host('stage') + ->set('hostname', 'stage.wms.mpba.dev') + ->set('remote_user', 'mag') + ->set('branch', 'develop') + ->set('deploy_path', '/var/www/wms/core'); + +/* + * Install the root npm tree, then every module that owns its own lock file, + * then run ONE root Vite build. Because Vite is compiling a module source file, + * normal Node resolution finds that module's own node_modules first. + */ +task('build', function (): void { + $node = 'export NVM_DIR="$HOME/.nvm"' + .' && [ -s "$NVM_DIR/nvm.sh" ]' + .' && . "$NVM_DIR/nvm.sh"' + .' && nvm use {{node_version}}'; + + run( + $node + .' && cd {{release_path}}' + .' && npm ci --no-audit --no-fund' + ); + + run( + $node + .' && cd {{release_path}}' + .' && for lock in Modules/*/package-lock.json; do' + .' [ -f "$lock" ] || continue;' + .' module_dir="$(dirname "$lock")";' + .' echo "[Modules] npm ci: $module_dir";' + .' (cd "$module_dir" && npm ci --no-audit --no-fund);' + .' done' + ); + + run( + $node + .' && cd {{release_path}}' + .' && npm run build' + ); +}); + +task('queue:restart', function (): void { + run('cd {{release_path}} && php artisan queue:restart'); +}); + +task('deploy:write_revision_file', function (): void { + run('cd {{release_path}} && echo "{{branch}}" > BRANCH'); + run('cd {{release_path}} && date -u +"%Y-%m-%dT%H:%M:%SZ" > DEPLOYED_AT'); +}); + +task('artisan:clear', function (): void { + run('cd {{release_path}} && php artisan view:clear'); + run('cd {{release_path}} && php artisan optimize:clear'); + run('cd {{release_path}} && php artisan config:clear'); + run('cd {{release_path}} && php artisan route:clear'); +}); + +task('services:restart', function (): void { + run('sudo -n nginx -t'); + run('sudo -n systemctl reload nginx'); + run('sudo -n systemctl restart php8.3-fpm'); +}); + +after('deploy:update_code', 'deploy:write_revision_file'); +after('deploy:vendors', 'build'); +after('deploy:symlink', 'artisan:clear'); +after('deploy:symlink', 'queue:restart'); +after('deploy:failed', 'deploy:unlock'); +after('deploy:symlink', 'services:restart');