new deployer command

This commit is contained in:
Mark Gregory
2026-09-16 12:14:43 +01:00
parent 16fe344eef
commit 5d58e35275
2 changed files with 262 additions and 374 deletions
+6 -2
View File
@@ -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;
}
+256 -372
View File
@@ -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 = <<<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{$terminateDatabase}'
AND pid <> 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<string, mixed> */
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<string, mixed> */
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<string, mixed> $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<string, mixed> $local @param array<string, mixed> $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<string, mixed> $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<string, mixed> $local @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $stage @return list<string> */
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<string, mixed> $local @param array<string, mixed> $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