144 lines
9.7 KiB
PHP
144 lines
9.7 KiB
PHP
<?php
|
|
|
|
// Only the already guarded QA target is dumped; restore creates a distinct *_test database.
|
|
use App\Services\PortalProfileInstallation;
|
|
use Illuminate\Contracts\Console\Kernel;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
require dirname(__DIR__).'/vendor/autoload.php';
|
|
require __DIR__.'/portal-qa-environment.php';
|
|
$target = portalQaEnvironment();
|
|
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
|
$app->make(Kernel::class)->bootstrap();
|
|
portalQaAssertTarget($target);
|
|
$directory = null;
|
|
// Laravel's CLI exception renderer may otherwise leave an unsuccessful harness at exit 0.
|
|
// Keep the last verified proof intact and invalidate only the QA readiness receipt.
|
|
set_exception_handler(static function (Throwable $error) use (&$directory, $target): void {
|
|
if (is_string($directory) && is_file($directory.'/command-failure.json')) {
|
|
$readiness = config('portal_profiles.proof_directory').'/readiness.json';
|
|
if (is_file($readiness)) {
|
|
copy($readiness, $directory.'/previous-readiness.json');
|
|
}
|
|
$failure = ['verified_at' => gmdate('c'), 'target' => $target,
|
|
'restore_verified' => false, 'files_verified' => false, 'qa_passed' => false,
|
|
'compatibility_verified' => false, 'active_writes' => 0,
|
|
'reason' => 'Latest QA backup/restore command failed; previous dated verified backups remain historical.',
|
|
'diagnostic' => $directory.'/command-failure.json'];
|
|
file_put_contents($readiness, json_encode($failure, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
file_put_contents(storage_path('app/private/investmoz-v2/portal-audit/profile-installation-backup-failure-qa.json'), json_encode($failure, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
}
|
|
try {
|
|
$pdo = DB::connection()->getPdo();
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
} catch (Throwable) {
|
|
// Preserve the original failure, without connection strings or exception bodies.
|
|
}
|
|
fwrite(STDERR, 'QA backup/restore not verified; diagnostic retained privately. Active environment untouched.'.PHP_EOL);
|
|
exit(1);
|
|
});
|
|
$code = app(PortalProfileInstallation::class)->fingerprint();
|
|
$tests = json_decode(file_get_contents(storage_path('app/private/investmoz-v2/portal-audit/profile-installation-checks-qa.json')), true, 512, JSON_THROW_ON_ERROR);
|
|
throw_unless(($tests['result'] ?? null) === 'passed' && ($tests['code_hash'] ?? null) === $code, RuntimeException::class, 'Current installation QA checks required.');
|
|
$c = DB::connection()->getConfig();
|
|
throw_unless(in_array($c['url'] ?? null, [null, ''], true) && str_ends_with($c['database'], '_test') && (int) $c['port'] === 55441 && $c['host'] === '127.0.0.1', RuntimeException::class, 'QA connection refused.');
|
|
$source = DB::connection()->getPdo();
|
|
$source->exec('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
$restoreName = 'profile_restore_'.gmdate('Ymd_His').'_test';
|
|
$directory = storage_path('app/private/investmoz-v2/portal-profile-installation/'.$restoreName);
|
|
mkdir($directory, 0700, true);
|
|
$dump = $directory.'/database.dump';
|
|
$run = function (array $args, string $password) use ($directory): void {
|
|
$process = new Process($args, null, ['PGPASSWORD' => $password]);
|
|
$process->setTimeout(300);
|
|
$process->run();
|
|
if (! $process->isSuccessful()) {
|
|
// Keep diagnostic output private; never expose connection secrets or dump content.
|
|
$stderr = $process->getErrorOutput();
|
|
if ($password !== '') {
|
|
$stderr = str_replace($password, '[REDACTED]', $stderr);
|
|
}
|
|
file_put_contents($directory.'/command-failure.json', json_encode([
|
|
'tool' => basename($args[0]), 'exit_code' => $process->getExitCode(),
|
|
'stderr' => $stderr, 'target_port' => 55441, 'active_access' => false,
|
|
], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
throw new RuntimeException('QA backup/restore command failed; diagnostic retained privately.');
|
|
}
|
|
};
|
|
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
|
$snapshot = $source->query('SELECT pg_export_snapshot()')->fetchColumn();
|
|
$run([$bin.'pg_dump.exe', '-h', $c['host'], '-p', '55441', '-U', $c['username'], '-d', $c['database'], '-Fc', '--snapshot='.$snapshot, '-f', $dump], $c['password'] ?? '');
|
|
$maintenance = new PDO('pgsql:host=127.0.0.1;port=55441;dbname=postgres', 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
throw_unless((int) $maintenance->query('SELECT inet_server_port()')->fetchColumn() === 55441 && preg_match('/^profile_restore_\d{8}_\d{6}_test$/', $restoreName), RuntimeException::class, 'Restore target refused.');
|
|
$maintenance->exec('CREATE DATABASE "'.$restoreName.'"');
|
|
$run([$bin.'pg_restore.exe', '-h', '127.0.0.1', '-p', '55441', '-U', 'postgres', '-d', $restoreName, '--exit-on-error', '--no-owner', '--no-privileges', $dump], '');
|
|
$restored = new PDO('pgsql:host=127.0.0.1;port=55441;dbname='.$restoreName, 'postgres', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
|
$identity = $restored->query('SELECT current_database() AS database,inet_server_port() AS port')->fetch(PDO::FETCH_ASSOC);
|
|
throw_unless($identity['database'] === $restoreName && (int) $identity['port'] === 55441, RuntimeException::class, 'Actual restore identity refused.');
|
|
$restored->exec('BEGIN READ ONLY');
|
|
$tableProof = [];
|
|
foreach ($source->query("SELECT schemaname,tablename FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY schemaname,tablename")->fetchAll(PDO::FETCH_ASSOC) as $table) {
|
|
$name = '"'.str_replace('"', '""', $table['schemaname']).'"."'.str_replace('"', '""', $table['tablename']).'"';
|
|
$sql = 'SELECT row_to_json(t)::text FROM '.$name.' t ORDER BY row_to_json(t)::text';
|
|
$a = $source->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
$b = $restored->query($sql)->fetchAll(PDO::FETCH_COLUMN);
|
|
throw_unless($a === $b, RuntimeException::class, 'Restored table contents differ.');
|
|
$tableProof[$name] = ['count' => count($a), 'sha256' => hash('sha256', implode("\n", $a))];
|
|
}
|
|
$documents = $source->query('SELECT storage_key FROM documents WHERE storage_key IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN);
|
|
$source->exec('ROLLBACK');
|
|
$restored->exec('ROLLBACK');
|
|
$files = $directory.'/private-files.zip';
|
|
$zip = new ZipArchive;
|
|
throw_unless($zip->open($files, ZipArchive::CREATE | ZipArchive::EXCL) === true, RuntimeException::class, 'Private archive unavailable.');
|
|
$root = realpath(storage_path('app/private'));
|
|
$manifest = [];
|
|
$missing = [];
|
|
foreach (array_unique($documents) as $relative) {
|
|
$path = realpath(storage_path('app/private/'.$relative));
|
|
if ($path === false) {
|
|
$missing[] = $relative;
|
|
|
|
continue;
|
|
}
|
|
throw_unless(str_starts_with(strtolower($path), strtolower($root.DIRECTORY_SEPARATOR)), RuntimeException::class, 'Document outside private storage.');
|
|
$name = 'private/'.str_replace('\\', '/', $relative);
|
|
$zip->addFile($path, $name);
|
|
$manifest[$name] = hash_file('sha256', $path);
|
|
}
|
|
// Preserve the QA runtime/key, never the active .env. Archive remains private.
|
|
$runtime = base_path('tools/portal-qa-environment.php');
|
|
$zip->addFile($runtime, 'runtime/portal-qa-environment.php');
|
|
$manifest['runtime/portal-qa-environment.php'] = hash_file('sha256', $runtime);
|
|
$zip->close();
|
|
$read = new ZipArchive;
|
|
$read->open($files);
|
|
$restoreFiles = $directory.'/files-restored';
|
|
mkdir($restoreFiles, 0700, true);
|
|
$restoreRoot = realpath($restoreFiles);
|
|
throw_unless(str_starts_with(strtolower($restoreRoot), strtolower(realpath(storage_path('app/private/investmoz-v2/portal-profile-installation')).DIRECTORY_SEPARATOR)), RuntimeException::class, 'File restore root refused.');
|
|
foreach ($manifest as $name => $hash) {
|
|
throw_unless(! str_contains($name, ':') && ! str_starts_with($name, '/') && ! array_intersect(explode('/', $name), ['.', '..']), RuntimeException::class, 'Archive member path refused.');
|
|
$bytes = $read->getFromName($name);
|
|
throw_unless(is_string($bytes) && hash('sha256', $bytes) === $hash, RuntimeException::class, 'Archived bytes differ.');
|
|
$destination = $restoreRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $name);
|
|
if (! is_dir(dirname($destination))) {
|
|
mkdir(dirname($destination), 0700, true);
|
|
}
|
|
throw_unless(str_starts_with(strtolower(realpath(dirname($destination))), strtolower($restoreRoot.DIRECTORY_SEPARATOR)), RuntimeException::class, 'Resolved restore destination refused.');
|
|
file_put_contents($destination, $bytes);
|
|
throw_unless(hash_file('sha256', $destination) === $hash, RuntimeException::class, 'Restored file differs.');
|
|
}
|
|
$read->close();
|
|
$proof = ['verified_at' => gmdate('c'), 'target' => $target, 'restored_target' => $identity, 'code_hash' => $code,
|
|
'restore_verified' => true, 'files_verified' => $missing === [], 'files_restore_verified' => true, 'files_restore_root' => $restoreRoot, 'compatibility_verified' => true, 'qa_passed' => true,
|
|
'dump_path' => $dump, 'dump_sha256' => hash_file('sha256', $dump), 'files_path' => $files, 'files_sha256' => hash_file('sha256', $files),
|
|
'tables' => $tableProof, 'files' => $manifest, 'missing_document_files' => $missing, 'active_writes' => 0,
|
|
'scope' => 'QA schema/data restore and archived byte recovery; not active backup, visual acceptance or recovery of lost MFA.', 'checks' => $tests];
|
|
file_put_contents($directory.'/proof.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
file_put_contents(config('portal_profiles.proof_directory').'/readiness.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
|
echo json_encode(['target' => $target, 'restored_target' => $identity, 'restore_verified' => true, 'tables' => count($tableProof), 'files' => count($manifest), 'missing_files' => count($missing), 'ready_files' => $missing === [], 'active_writes' => 0], JSON_THROW_ON_ERROR).PHP_EOL;
|