1641 lines
63 KiB
PHP
1641 lines
63 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/classes/backup_schema_bootstrap.php';
|
|
require_once WD . '/modules/backups/backups_c.php';
|
|
|
|
use backups\backups_c;
|
|
use Exception;
|
|
use interfaces\minio_backups_i;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
use traits\minio_t;
|
|
|
|
class backup_store implements minio_backups_i
|
|
{
|
|
use minio_t;
|
|
|
|
public const BACKUP_SCHEMA_VERSION = 2;
|
|
public const backup_s3_prefix = 'backups/';
|
|
public const metadata_s3_prefix = 'metadata/';
|
|
public const local_backup_path = '/tmp/backups/';
|
|
private const BACKUP_BUCKET = 'backups';
|
|
private const APP_DATA_BUCKETS = ['pdfs', 'uploads', 'attachments', 'invoices', 'truckwashdev'];
|
|
|
|
public backups_c $config;
|
|
private string $lock_owner;
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
public function __construct()
|
|
{
|
|
backup_schema_bootstrap::ensureTables();
|
|
$this->config = new backups_c();
|
|
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
|
|
self::setBucket(self::BACKUP_BUCKET);
|
|
self::validateBucket();
|
|
}
|
|
|
|
public function validateBucket(): void
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
$this->localTestBucketDirectory(self::BACKUP_BUCKET);
|
|
return;
|
|
}
|
|
|
|
if (!self::getS3Client()->doesBucketExist(self::getBucket())) {
|
|
throw new Exception('The bucket does not exist in the S3 service! Missing: ' . self::getBucket());
|
|
}
|
|
}
|
|
|
|
public function backup_exists(string $backup_uuid): bool
|
|
{
|
|
$record = $this->backupRecord($backup_uuid);
|
|
if ($record !== null) {
|
|
return !in_array((string)$record['status'], ['failed', 'pruned'], true);
|
|
}
|
|
|
|
return $this->objectExists(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
|
|
}
|
|
|
|
public function getBackupDownloadUrl(string $backup_uuid): string
|
|
{
|
|
$record = $this->backupRecord($backup_uuid);
|
|
if ($record !== null && (string)($record['manifest_key'] ?? '') !== '') {
|
|
return self::getPresignedUrl((string)$record['manifest_key']);
|
|
}
|
|
|
|
return self::getPresignedUrl(self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
|
|
}
|
|
|
|
public function download(string $backup_uuid): string
|
|
{
|
|
$path = self::local_backup_path . 'backup_' . $backup_uuid . '.zip';
|
|
$this->ensureLocalDirectory(dirname($path));
|
|
$body = $this->getObjectBody(self::BACKUP_BUCKET, self::backup_s3_prefix . 'backup_' . $backup_uuid . '.zip');
|
|
file_put_contents($path, $body);
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Legacy synchronous entrypoint. New HTTP routes should use enqueueCreateBackup().
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function createBackup($backup_name = null, $backup_description = null): string
|
|
{
|
|
$queued = $this->enqueueCreateBackup($backup_name, $backup_description, null, 'cron');
|
|
$job = $this->runJobById((int)$queued['job_id']);
|
|
if (!in_array((string)$job['status'], ['succeeded', 'created_unverified'], true)) {
|
|
throw new RuntimeException((string)($job['error_message'] ?? 'Backup failed.'));
|
|
}
|
|
|
|
return (string)$queued['backup_uuid'];
|
|
}
|
|
|
|
public function enqueueCreateBackup($backup_name = null, $backup_description = null, ?int $actor_user_id = null, string $source = 'manual'): array
|
|
{
|
|
if (!$this->configBool('enabled', true)) {
|
|
throw new RuntimeException('Backup system is disabled in backup configuration.');
|
|
}
|
|
|
|
$backup_uuid = self::uuid();
|
|
$now = date('Y-m-d H:i:s');
|
|
$backup_name = trim((string)($backup_name ?: 'Backup ' . $now));
|
|
$backup_description = trim((string)($backup_description ?: 'Backup created on ' . $now));
|
|
$storage_prefix = self::backup_s3_prefix . $backup_uuid . '/';
|
|
|
|
$this->query(
|
|
"INSERT INTO backup_records
|
|
(backup_uuid, name, description, source, status, schema_version, storage_bucket, storage_prefix, requested_by_user_id)
|
|
VALUES ("
|
|
. $this->sql($backup_uuid) . ', '
|
|
. $this->sql($backup_name) . ', '
|
|
. $this->nullableSql($backup_description) . ', '
|
|
. $this->sql($source) . ", 'queued', "
|
|
. self::BACKUP_SCHEMA_VERSION . ', '
|
|
. $this->sql(self::BACKUP_BUCKET) . ', '
|
|
. $this->sql($storage_prefix) . ', '
|
|
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id)
|
|
. ")"
|
|
);
|
|
|
|
$job_id = $this->createJob('create', $backup_uuid, [
|
|
'name' => $backup_name,
|
|
'description' => $backup_description,
|
|
'source' => $source,
|
|
], $actor_user_id);
|
|
|
|
return [
|
|
'job_id' => $job_id,
|
|
'backup_uuid' => $backup_uuid,
|
|
'status' => 'queued',
|
|
];
|
|
}
|
|
|
|
public function enqueueVerifyBackup(string $backup_uuid, ?int $actor_user_id = null): array
|
|
{
|
|
$this->requireBackupRecord($backup_uuid);
|
|
$job_id = $this->createJob('verify', $backup_uuid, [], $actor_user_id);
|
|
|
|
return [
|
|
'job_id' => $job_id,
|
|
'backup_uuid' => $backup_uuid,
|
|
'status' => 'queued',
|
|
];
|
|
}
|
|
|
|
public function previewRestore(string $backup_uuid, ?int $actor_user_id = null): array
|
|
{
|
|
$record = $this->requireBackupRecord($backup_uuid);
|
|
if ((string)$record['status'] !== 'available') {
|
|
throw new RuntimeException('Only verified available backups can be restored.');
|
|
}
|
|
|
|
$manifest = $this->loadManifest($record);
|
|
$checks = $this->restorePreflightChecks($record, $manifest);
|
|
$expires_at = date('c', time() + 900);
|
|
$confirmation_phrase = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
|
|
$preview = [
|
|
'backup_uuid' => $backup_uuid,
|
|
'backup_name' => $record['name'],
|
|
'target_environment' => 'production',
|
|
'expires_at' => $expires_at,
|
|
'confirmation_phrase' => $confirmation_phrase,
|
|
'checks' => $checks,
|
|
'destructive_impact' => [
|
|
'database' => 'The current production database will be replaced from the backup dump after a pre-restore backup is created.',
|
|
'object_data' => 'App-owned object buckets will be reconciled to the backup manifest. Extra objects are moved to restore quarantine.',
|
|
],
|
|
];
|
|
|
|
$job_id = $this->createJob('restore_preview', $backup_uuid, ['expires_at' => $expires_at], $actor_user_id, 'succeeded', 100, 'Restore preview ready.', $preview);
|
|
|
|
return $preview + ['preview_id' => $job_id];
|
|
}
|
|
|
|
public function enqueueRestore(string $backup_uuid, int $preview_job_id, string $confirmation_phrase, string $reason, ?int $actor_user_id = null, array $request_context = []): array
|
|
{
|
|
if (!$this->configBool('restore_enabled', false)) {
|
|
throw new RuntimeException('Direct production restore is disabled in backup configuration.');
|
|
}
|
|
|
|
$record = $this->requireBackupRecord($backup_uuid);
|
|
if ((string)$record['status'] !== 'available') {
|
|
throw new RuntimeException('Only verified available backups can be restored.');
|
|
}
|
|
|
|
$preview = $this->getJob($preview_job_id);
|
|
if ($preview === null || (string)$preview['job_type'] !== 'restore_preview' || (string)$preview['backup_uuid'] !== $backup_uuid) {
|
|
throw new RuntimeException('Restore preview is missing or does not match this backup.');
|
|
}
|
|
|
|
$result = $this->decodeJson($preview['result_json'] ?? null);
|
|
if (strtotime((string)($result['expires_at'] ?? '')) < time()) {
|
|
throw new RuntimeException('Restore preview has expired.');
|
|
}
|
|
|
|
$expected = 'RESTORE ' . $backup_uuid . ' TO PRODUCTION';
|
|
if (!hash_equals($expected, trim($confirmation_phrase))) {
|
|
throw new RuntimeException('Restore confirmation phrase did not match.');
|
|
}
|
|
|
|
$reason = trim($reason);
|
|
if ($reason === '') {
|
|
throw new RuntimeException('Restore reason is required.');
|
|
}
|
|
|
|
$job_id = $this->createJob('restore', $backup_uuid, [
|
|
'preview_job_id' => $preview_job_id,
|
|
'reason' => $reason,
|
|
'request_context' => $request_context,
|
|
], $actor_user_id);
|
|
|
|
$this->query(
|
|
"INSERT INTO backup_restore_audit
|
|
(restore_job_id, preview_job_id, backup_uuid, actor_user_id, target_environment, confirmation_fingerprint, reason, ip_address, user_agent, status)
|
|
VALUES ("
|
|
. (string)$job_id . ', '
|
|
. (string)$preview_job_id . ', '
|
|
. $this->sql($backup_uuid) . ', '
|
|
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ", 'production', "
|
|
. $this->sql(hash('sha256', $confirmation_phrase)) . ', '
|
|
. $this->nullableSql($reason) . ', '
|
|
. $this->nullableSql((string)($request_context['ip_address'] ?? '')) . ', '
|
|
. $this->nullableSql(substr((string)($request_context['user_agent'] ?? ''), 0, 255)) . ", 'queued')"
|
|
);
|
|
|
|
return [
|
|
'job_id' => $job_id,
|
|
'backup_uuid' => $backup_uuid,
|
|
'status' => 'queued',
|
|
];
|
|
}
|
|
|
|
public function enqueueRetentionPrune(?int $actor_user_id = null): array
|
|
{
|
|
$job_id = $this->createJob('prune', null, [], $actor_user_id);
|
|
|
|
return [
|
|
'job_id' => $job_id,
|
|
'status' => 'queued',
|
|
];
|
|
}
|
|
|
|
public function processPendingJobs(int $limit = 5): array
|
|
{
|
|
$limit = max(1, min(25, $limit));
|
|
$jobs = $this->fetchAll(
|
|
"SELECT * FROM backup_jobs WHERE status = 'queued' ORDER BY id ASC LIMIT $limit"
|
|
);
|
|
$processed = [];
|
|
|
|
foreach ($jobs as $job) {
|
|
$processed[] = $this->runJobById((int)$job['id']);
|
|
}
|
|
|
|
return [
|
|
'processed' => $processed,
|
|
'count' => count($processed),
|
|
];
|
|
}
|
|
|
|
public function runJobById(int $job_id): array
|
|
{
|
|
$job = $this->getJob($job_id);
|
|
if ($job === null) {
|
|
throw new RuntimeException('Backup job not found.');
|
|
}
|
|
|
|
if ((string)$job['status'] !== 'queued') {
|
|
return $job + ['result' => $this->decodeJson($job['result_json'] ?? null)];
|
|
}
|
|
|
|
$this->query(
|
|
"UPDATE backup_jobs
|
|
SET status = 'running',
|
|
started_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
|
|
locked_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
|
|
lock_owner = " . $this->sql($this->lock_owner) . "
|
|
WHERE id = " . (string)$job_id . " AND status = 'queued'"
|
|
);
|
|
if ($this->affectedRows() !== 1) {
|
|
return $this->getJob($job_id) ?? $job;
|
|
}
|
|
|
|
$status = 'succeeded';
|
|
$message = 'Backup job completed.';
|
|
$result = [];
|
|
$error = null;
|
|
|
|
try {
|
|
$type = (string)$job['job_type'];
|
|
$payload = $this->decodeJson($job['payload_json'] ?? null);
|
|
if ($type === 'create') {
|
|
$result = $this->performCreateBackup((string)$job['backup_uuid'], $payload);
|
|
$status = (string)($result['status'] ?? 'succeeded');
|
|
$message = (string)($result['message'] ?? 'Backup created.');
|
|
} elseif ($type === 'verify') {
|
|
$result = $this->performVerification((string)$job['backup_uuid'], true);
|
|
$status = (string)($result['job_status'] ?? 'succeeded');
|
|
$message = (string)($result['message'] ?? 'Backup verification completed.');
|
|
} elseif ($type === 'restore') {
|
|
$result = $this->performRestore((string)$job['backup_uuid'], $payload, (int)($job['actor_user_id'] ?? 0) ?: null, $job_id);
|
|
$message = 'Restore completed.';
|
|
} elseif ($type === 'prune') {
|
|
$result = $this->pruneRetention();
|
|
$message = 'Backup retention pruned.';
|
|
} else {
|
|
throw new RuntimeException('Unsupported backup job type: ' . $type);
|
|
}
|
|
} catch (Throwable $throwable) {
|
|
$status = 'failed';
|
|
$error = $throwable->getMessage();
|
|
$message = $error;
|
|
}
|
|
|
|
$this->completeJob($job_id, $status, $message, $result, $error);
|
|
return $this->getJob($job_id) + ['result' => $result];
|
|
}
|
|
|
|
public function getJob(int $job_id): ?array
|
|
{
|
|
$job = $this->fetchOne("SELECT * FROM backup_jobs WHERE id = " . (string)$job_id);
|
|
if ($job === null) {
|
|
return null;
|
|
}
|
|
$job['payload'] = $this->decodeJson($job['payload_json'] ?? null);
|
|
$job['result'] = $this->decodeJson($job['result_json'] ?? null);
|
|
return $job;
|
|
}
|
|
|
|
public function listJobs(?string $backup_uuid = null, int $limit = 50): array
|
|
{
|
|
$limit = max(1, min(200, $limit));
|
|
$where = $backup_uuid !== null && $backup_uuid !== ''
|
|
? "WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
: '';
|
|
|
|
return [
|
|
'jobs' => $this->fetchAll("SELECT * FROM backup_jobs $where ORDER BY id DESC LIMIT $limit"),
|
|
];
|
|
}
|
|
|
|
public function generateBackupFile(): string
|
|
{
|
|
$uuid = $this->createBackup();
|
|
return self::local_backup_path . 'backup_' . $uuid . '.zip';
|
|
}
|
|
|
|
public function cleanupBackupFile(string $backup_uuid): void
|
|
{
|
|
$path = self::local_backup_path . 'backup_' . $backup_uuid;
|
|
if (is_file($path . '.zip')) {
|
|
@unlink($path . '.zip');
|
|
}
|
|
if (is_file($path . '.json')) {
|
|
@unlink($path . '.json');
|
|
}
|
|
if (is_dir($path)) {
|
|
$this->removeDirectory($path);
|
|
}
|
|
}
|
|
|
|
public function backupDatabase(string $backup_uuid): string
|
|
{
|
|
$component = $this->createDatabaseComponent($backup_uuid);
|
|
return (string)$component['storage_key'];
|
|
}
|
|
|
|
public function backupServerFiles(string $backup_uuid): string
|
|
{
|
|
throw new RuntimeException('Full server filesystem backups are intentionally not supported. Restore application source from deployment artifacts.');
|
|
}
|
|
|
|
public function backupEnvironmentVariables(string $backup_uuid): string
|
|
{
|
|
$key = self::backup_s3_prefix . $backup_uuid . '/runtime-manifest.json';
|
|
$manifest = [
|
|
'generated_at' => date('c'),
|
|
'required_config_keys' => $this->sanitizedRuntimeConfigKeys(),
|
|
];
|
|
$this->putObjectBody(self::BACKUP_BUCKET, $key, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
return $key;
|
|
}
|
|
|
|
public function createBackupMetadata(string $backup_uuid, string $backup_name, string $backup_description): void
|
|
{
|
|
$record = $this->backupRecord($backup_uuid);
|
|
$metadata = [
|
|
'backup_uuid' => $backup_uuid,
|
|
'backup_name' => $backup_name,
|
|
'backup_description' => $backup_description,
|
|
'backup_date' => date('Y-m-d H:i:s'),
|
|
'status' => $record['status'] ?? 'legacy',
|
|
'hash' => $record['manifest_sha256'] ?? null,
|
|
];
|
|
$this->putObjectBody(
|
|
self::BACKUP_BUCKET,
|
|
self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json',
|
|
json_encode($metadata, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
}
|
|
|
|
public function listBackups(?int $limit = 50, int $offset = 0): array
|
|
{
|
|
$limit = max(1, min(200, (int)$limit));
|
|
$offset = max(0, $offset);
|
|
$total = (int)($this->fetchOne("SELECT COUNT(*) AS total FROM backup_records")['total'] ?? 0);
|
|
$records = $this->fetchAll(
|
|
"SELECT * FROM backup_records ORDER BY created_at DESC LIMIT $limit OFFSET $offset"
|
|
);
|
|
|
|
foreach ($records as &$record) {
|
|
$record['components'] = $this->componentsForBackup((string)$record['backup_uuid']);
|
|
}
|
|
|
|
return [
|
|
'items' => $records,
|
|
'legacy_items' => $offset === 0 ? $this->legacyBackups() : [],
|
|
'total' => $total,
|
|
'limit' => $limit,
|
|
'offset' => $offset,
|
|
'has_more' => ($offset + count($records)) < $total,
|
|
];
|
|
}
|
|
|
|
public function getBackupMetadata(string $backup_uuid): array
|
|
{
|
|
$record = $this->backupRecord($backup_uuid);
|
|
if ($record !== null) {
|
|
return $record + ['components' => $this->componentsForBackup($backup_uuid)];
|
|
}
|
|
|
|
$metadata = $this->getObjectBody(self::BACKUP_BUCKET, self::metadata_s3_prefix . 'backup_' . $backup_uuid . '.json');
|
|
return json_decode($metadata, true) ?: [];
|
|
}
|
|
|
|
public function restoreAudit(int $limit = 50): array
|
|
{
|
|
$limit = max(1, min(200, $limit));
|
|
return [
|
|
'items' => $this->fetchAll("SELECT * FROM backup_restore_audit ORDER BY id DESC LIMIT $limit"),
|
|
];
|
|
}
|
|
|
|
public function latestVerifiedBackup(): ?array
|
|
{
|
|
return $this->fetchOne(
|
|
"SELECT * FROM backup_records WHERE status = 'available' AND verified_at IS NOT NULL ORDER BY verified_at DESC LIMIT 1"
|
|
);
|
|
}
|
|
|
|
public function healthSummary(): array
|
|
{
|
|
$latest = $this->latestVerifiedBackup();
|
|
$encryption = $this->encryptionStatus();
|
|
$latestAgeSeconds = null;
|
|
if ($latest !== null && strtotime((string)$latest['verified_at']) !== false) {
|
|
$latestAgeSeconds = time() - strtotime((string)$latest['verified_at']);
|
|
}
|
|
|
|
$fresh = $latestAgeSeconds !== null && $latestAgeSeconds <= 4500;
|
|
|
|
return [
|
|
'latest_verified_backup' => $latest,
|
|
'latest_verified_age_seconds' => $latestAgeSeconds,
|
|
'fresh' => $fresh,
|
|
'encryption' => $encryption,
|
|
'verification_required' => $this->configBool('verification_required', true),
|
|
'restore_enabled' => $this->configBool('restore_enabled', false),
|
|
];
|
|
}
|
|
|
|
private function performCreateBackup(string $backup_uuid, array $payload): array
|
|
{
|
|
$record = $this->requireBackupRecord($backup_uuid);
|
|
$started_at = date('Y-m-d H:i:s');
|
|
$this->updateJobProgressForBackup($backup_uuid, 5, 'Preparing backup.');
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'creating', started_at = " . $this->sql($started_at) . ", last_error = NULL
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
|
|
$components = [];
|
|
$this->clearComponents($backup_uuid);
|
|
$this->ensureLocalDirectory(self::local_backup_path . $backup_uuid);
|
|
|
|
$components[] = $this->createDatabaseComponent($backup_uuid);
|
|
$this->updateJobProgressForBackup($backup_uuid, 45, 'Database component created.');
|
|
|
|
if ($this->configBool('app_data_enabled', true)) {
|
|
foreach (self::APP_DATA_BUCKETS as $bucket) {
|
|
$components[] = $this->createObjectBucketComponent($backup_uuid, $bucket);
|
|
}
|
|
}
|
|
$this->updateJobProgressForBackup($backup_uuid, 80, 'Object components created.');
|
|
|
|
$manifest = [
|
|
'schema_version' => self::BACKUP_SCHEMA_VERSION,
|
|
'backup_uuid' => $backup_uuid,
|
|
'name' => $record['name'],
|
|
'description' => $record['description'],
|
|
'source' => $record['source'],
|
|
'created_at' => date('c'),
|
|
'app_identifier' => $this->appIdentifier(),
|
|
'storage_bucket' => self::BACKUP_BUCKET,
|
|
'storage_prefix' => self::backup_s3_prefix . $backup_uuid . '/',
|
|
'encryption_key_id' => $this->currentEncryptionKey()['id'],
|
|
'required_runtime_config_keys' => $this->sanitizedRuntimeConfigKeys(),
|
|
'components' => $components,
|
|
];
|
|
|
|
$manifest_json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
if ($manifest_json === false) {
|
|
throw new RuntimeException('Could not encode backup manifest.');
|
|
}
|
|
|
|
$manifest_key = self::backup_s3_prefix . $backup_uuid . '/manifest.json';
|
|
$this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $manifest_json, 'application/json');
|
|
$manifest_sha256 = hash('sha256', $manifest_json);
|
|
|
|
$component_count = count(array_filter($components, static fn(array $component): bool => ($component['status'] ?? '') === 'stored'));
|
|
$object_count = array_sum(array_map(static fn(array $component): int => (int)($component['object_count'] ?? 0), $components));
|
|
$total_bytes = array_sum(array_map(static fn(array $component): int => (int)($component['byte_size'] ?? 0), $components));
|
|
$completed_at = date('Y-m-d H:i:s');
|
|
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'created_unverified',
|
|
completed_at = " . $this->sql($completed_at) . ",
|
|
manifest_key = " . $this->sql($manifest_key) . ",
|
|
manifest_sha256 = " . $this->sql($manifest_sha256) . ",
|
|
encryption_key_id = " . $this->sql($manifest['encryption_key_id']) . ",
|
|
component_count = " . (string)$component_count . ",
|
|
object_count = " . (string)$object_count . ",
|
|
total_bytes = " . (string)$total_bytes . "
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
|
|
$this->createBackupMetadata($backup_uuid, (string)$record['name'], (string)($record['description'] ?? ''));
|
|
|
|
if ($this->configBool('verification_required', true)) {
|
|
$verification = $this->performVerification($backup_uuid, true);
|
|
return [
|
|
'backup_uuid' => $backup_uuid,
|
|
'status' => (string)($verification['job_status'] ?? 'created_unverified'),
|
|
'message' => (string)($verification['message'] ?? 'Backup created but verification did not complete.'),
|
|
'verification' => $verification,
|
|
];
|
|
}
|
|
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . "
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
|
|
return [
|
|
'backup_uuid' => $backup_uuid,
|
|
'status' => 'succeeded',
|
|
'message' => 'Backup created and marked available without scratch verification.',
|
|
];
|
|
}
|
|
|
|
private function createDatabaseComponent(string $backup_uuid): array
|
|
{
|
|
global $db;
|
|
|
|
if (!$db instanceof db) {
|
|
throw new RuntimeException('Database connection is not available.');
|
|
}
|
|
|
|
$dir = self::local_backup_path . $backup_uuid;
|
|
$this->ensureLocalDirectory($dir);
|
|
$dump_path = $dir . '/database.sql';
|
|
if (!$db->backupDatabase($dump_path)) {
|
|
throw new RuntimeException('Failed to dump the database.');
|
|
}
|
|
|
|
$dump = file_get_contents($dump_path);
|
|
if ($dump === false) {
|
|
throw new RuntimeException('Could not read database dump.');
|
|
}
|
|
$compressed = gzencode($dump, 6);
|
|
if ($compressed === false) {
|
|
throw new RuntimeException('Could not compress database dump.');
|
|
}
|
|
|
|
$storage_key = self::backup_s3_prefix . $backup_uuid . '/components/database.sql.gz.enc';
|
|
$aad = ['backup_uuid' => $backup_uuid, 'component' => 'database', 'storage_key' => $storage_key];
|
|
$encrypted = $this->encryptString($compressed, $aad);
|
|
$this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
|
|
@unlink($dump_path);
|
|
@unlink($dump_path . '.error.log');
|
|
|
|
$component = [
|
|
'component_type' => 'database',
|
|
'logical_name' => $db->getDatabase(),
|
|
'source_bucket' => null,
|
|
'source_prefix' => null,
|
|
'storage_key' => $storage_key,
|
|
'object_count' => 1,
|
|
'byte_size' => strlen($compressed),
|
|
'content_sha256' => hash('sha256', $compressed),
|
|
'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
|
|
'encryption' => $encrypted['metadata'],
|
|
'aad' => $aad,
|
|
'status' => 'stored',
|
|
];
|
|
|
|
$this->insertComponent($backup_uuid, $component);
|
|
return $component;
|
|
}
|
|
|
|
private function createObjectBucketComponent(string $backup_uuid, string $bucket): array
|
|
{
|
|
$objects = [];
|
|
$status = 'stored';
|
|
$error = null;
|
|
|
|
try {
|
|
if (!$this->bucketExists($bucket)) {
|
|
$status = 'skipped';
|
|
$error = 'Bucket is not available.';
|
|
} else {
|
|
foreach ($this->listObjectsInBucket($bucket) as $object) {
|
|
$source_key = (string)$object['Key'];
|
|
if ($source_key === '' || str_ends_with($source_key, '/')) {
|
|
continue;
|
|
}
|
|
$body = $this->getObjectBody($bucket, $source_key);
|
|
$storage_key = self::backup_s3_prefix . $backup_uuid . '/objects/' . $bucket . '/' . $this->encodeObjectKey($source_key) . '.enc';
|
|
$aad = ['backup_uuid' => $backup_uuid, 'component' => 'object', 'bucket' => $bucket, 'source_key' => $source_key, 'storage_key' => $storage_key];
|
|
$encrypted = $this->encryptString($body, $aad);
|
|
$this->putObjectBody(self::BACKUP_BUCKET, $storage_key, $encrypted['ciphertext']);
|
|
$objects[] = [
|
|
'source_key' => $source_key,
|
|
'size' => strlen($body),
|
|
'content_sha256' => hash('sha256', $body),
|
|
'storage_key' => $storage_key,
|
|
'encrypted_sha256' => hash('sha256', $encrypted['ciphertext']),
|
|
'encryption' => $encrypted['metadata'],
|
|
'aad' => $aad,
|
|
];
|
|
}
|
|
}
|
|
} catch (Throwable $throwable) {
|
|
$status = 'failed';
|
|
$error = $throwable->getMessage();
|
|
}
|
|
|
|
$manifest_key = self::backup_s3_prefix . $backup_uuid . '/components/' . $bucket . '.manifest.json';
|
|
$bucket_manifest = json_encode([
|
|
'bucket' => $bucket,
|
|
'created_at' => date('c'),
|
|
'objects' => $objects,
|
|
'status' => $status,
|
|
'error' => $error,
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$this->putObjectBody(self::BACKUP_BUCKET, $manifest_key, $bucket_manifest ?: '{}', 'application/json');
|
|
|
|
$component = [
|
|
'component_type' => 'object_bucket',
|
|
'logical_name' => $bucket,
|
|
'source_bucket' => $bucket,
|
|
'source_prefix' => '',
|
|
'storage_key' => null,
|
|
'manifest_key' => $manifest_key,
|
|
'object_count' => count($objects),
|
|
'byte_size' => array_sum(array_map(static fn(array $object): int => (int)$object['size'], $objects)),
|
|
'content_sha256' => $bucket_manifest !== false ? hash('sha256', $bucket_manifest) : null,
|
|
'encrypted_sha256' => null,
|
|
'encryption' => null,
|
|
'objects' => $objects,
|
|
'status' => $status,
|
|
'error' => $error,
|
|
];
|
|
|
|
$this->insertComponent($backup_uuid, $component);
|
|
return $component;
|
|
}
|
|
|
|
private function performVerification(string $backup_uuid, bool $require_scratch_db): array
|
|
{
|
|
$record = $this->requireBackupRecord($backup_uuid);
|
|
$manifest = $this->loadManifest($record);
|
|
$failures = [];
|
|
|
|
if (hash('sha256', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) !== (string)$record['manifest_sha256']) {
|
|
$manifest_body = $this->getObjectBody(self::BACKUP_BUCKET, (string)$record['manifest_key']);
|
|
if (hash('sha256', $manifest_body) !== (string)$record['manifest_sha256']) {
|
|
$failures[] = 'Manifest hash mismatch.';
|
|
}
|
|
}
|
|
|
|
foreach (($manifest['components'] ?? []) as $component) {
|
|
if (($component['status'] ?? '') === 'skipped') {
|
|
continue;
|
|
}
|
|
if (($component['status'] ?? '') !== 'stored') {
|
|
$failures[] = 'Component failed: ' . (string)($component['logical_name'] ?? 'unknown');
|
|
continue;
|
|
}
|
|
if (($component['component_type'] ?? '') === 'database') {
|
|
$this->verifyDatabaseComponent($component, $require_scratch_db, $failures);
|
|
}
|
|
if (($component['component_type'] ?? '') === 'object_bucket') {
|
|
$this->verifyObjectBucketComponent($component, $failures);
|
|
}
|
|
}
|
|
|
|
if ($failures !== []) {
|
|
$error = implode(' ', $failures);
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'created_unverified', last_error = " . $this->sql($error) . "
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
return [
|
|
'backup_uuid' => $backup_uuid,
|
|
'job_status' => 'created_unverified',
|
|
'message' => $error,
|
|
'failures' => $failures,
|
|
];
|
|
}
|
|
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'available', verified_at = " . $this->sql(date('Y-m-d H:i:s')) . ", last_error = NULL
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
|
|
return [
|
|
'backup_uuid' => $backup_uuid,
|
|
'job_status' => 'succeeded',
|
|
'message' => 'Backup manifest, encrypted components, and verification target were validated.',
|
|
];
|
|
}
|
|
|
|
private function verifyDatabaseComponent(array $component, bool $require_scratch_db, array &$failures): void
|
|
{
|
|
$storage_key = (string)($component['storage_key'] ?? '');
|
|
if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
|
|
$failures[] = 'Database component object is missing.';
|
|
return;
|
|
}
|
|
|
|
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
|
|
if (hash('sha256', $ciphertext) !== (string)($component['encrypted_sha256'] ?? '')) {
|
|
$failures[] = 'Database component encrypted hash mismatch.';
|
|
return;
|
|
}
|
|
|
|
$compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
|
|
if (hash('sha256', $compressed) !== (string)($component['content_sha256'] ?? '')) {
|
|
$failures[] = 'Database component plaintext hash mismatch.';
|
|
return;
|
|
}
|
|
|
|
$sql = gzdecode($compressed);
|
|
if ($sql === false || trim($sql) === '') {
|
|
$failures[] = 'Database dump could not be decompressed.';
|
|
return;
|
|
}
|
|
|
|
$verifyConfig = $this->verificationDatabaseConfig();
|
|
if ($verifyConfig === null) {
|
|
if ($require_scratch_db) {
|
|
$failures[] = 'Backup verification database is not configured.';
|
|
}
|
|
return;
|
|
}
|
|
|
|
$this->importSqlIntoDatabase($sql, $verifyConfig, 'verify');
|
|
}
|
|
|
|
private function verifyObjectBucketComponent(array $component, array &$failures): void
|
|
{
|
|
foreach (($component['objects'] ?? []) as $object) {
|
|
$storage_key = (string)($object['storage_key'] ?? '');
|
|
if ($storage_key === '' || !$this->objectExists(self::BACKUP_BUCKET, $storage_key)) {
|
|
$failures[] = 'Object backup is missing for ' . (string)($component['logical_name'] ?? 'bucket') . '/' . (string)($object['source_key'] ?? 'unknown');
|
|
continue;
|
|
}
|
|
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, $storage_key);
|
|
if (hash('sha256', $ciphertext) !== (string)($object['encrypted_sha256'] ?? '')) {
|
|
$failures[] = 'Object backup encrypted hash mismatch for ' . (string)($object['source_key'] ?? 'unknown');
|
|
}
|
|
}
|
|
}
|
|
|
|
private function performRestore(string $backup_uuid, array $payload, ?int $actor_user_id, int $job_id): array
|
|
{
|
|
$record = $this->requireBackupRecord($backup_uuid);
|
|
$manifest = $this->loadManifest($record);
|
|
$owner = 'backup-restore:' . $job_id;
|
|
$pre_restore_uuid = null;
|
|
|
|
$this->query(
|
|
"UPDATE backup_restore_audit
|
|
SET status = 'running', started_at = " . $this->sql(date('Y-m-d H:i:s')) . "
|
|
WHERE restore_job_id = " . (string)$job_id
|
|
);
|
|
|
|
application_write_freeze::freeze('Backup restore in progress.', $owner, 7200);
|
|
try {
|
|
$pre = $this->enqueueCreateBackup('Pre-restore backup ' . date('Y-m-d H:i:s'), 'Automatic backup before restoring ' . $backup_uuid, $actor_user_id, 'pre_restore');
|
|
$pre_restore_uuid = (string)$pre['backup_uuid'];
|
|
$preJob = $this->runJobById((int)$pre['job_id']);
|
|
if ((string)($preJob['status'] ?? '') !== 'succeeded') {
|
|
throw new RuntimeException('Pre-restore backup did not complete as a verified backup.');
|
|
}
|
|
|
|
foreach (($manifest['components'] ?? []) as $component) {
|
|
if (($component['component_type'] ?? '') === 'database') {
|
|
$this->restoreDatabaseComponent($component);
|
|
}
|
|
}
|
|
foreach (($manifest['components'] ?? []) as $component) {
|
|
if (($component['component_type'] ?? '') === 'object_bucket') {
|
|
$this->restoreObjectBucketComponent($backup_uuid, $component);
|
|
}
|
|
}
|
|
|
|
$this->query(
|
|
"UPDATE backup_restore_audit
|
|
SET status = 'succeeded',
|
|
pre_restore_backup_uuid = " . $this->sql($pre_restore_uuid) . ",
|
|
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . "
|
|
WHERE restore_job_id = " . (string)$job_id
|
|
);
|
|
} catch (Throwable $throwable) {
|
|
$this->query(
|
|
"UPDATE backup_restore_audit
|
|
SET status = 'failed',
|
|
pre_restore_backup_uuid = " . $this->nullableSql($pre_restore_uuid) . ",
|
|
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
|
|
error_message = " . $this->sql($throwable->getMessage()) . "
|
|
WHERE restore_job_id = " . (string)$job_id
|
|
);
|
|
throw $throwable;
|
|
} finally {
|
|
application_write_freeze::unfreeze($owner);
|
|
}
|
|
|
|
return [
|
|
'backup_uuid' => $backup_uuid,
|
|
'pre_restore_backup_uuid' => $pre_restore_uuid,
|
|
'status' => 'succeeded',
|
|
];
|
|
}
|
|
|
|
private function restoreDatabaseComponent(array $component): void
|
|
{
|
|
global $db;
|
|
|
|
if (!$db instanceof db) {
|
|
throw new RuntimeException('Production database connection is not available.');
|
|
}
|
|
|
|
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$component['storage_key']);
|
|
$compressed = $this->decryptString($ciphertext, $component['encryption'] ?? [], $component['aad'] ?? []);
|
|
$sql = gzdecode($compressed);
|
|
if ($sql === false || trim($sql) === '') {
|
|
throw new RuntimeException('Restore database dump could not be decompressed.');
|
|
}
|
|
|
|
$this->importSqlIntoDatabase($sql, [
|
|
'host' => $db->getHost(),
|
|
'port' => $db->getPort(),
|
|
'user' => $db->getUsername(),
|
|
'password' => $db->getPassword(),
|
|
'database' => $db->getDatabase(),
|
|
], 'restore');
|
|
}
|
|
|
|
private function restoreObjectBucketComponent(string $backup_uuid, array $component): void
|
|
{
|
|
$bucket = (string)($component['source_bucket'] ?? '');
|
|
if ($bucket === '') {
|
|
return;
|
|
}
|
|
|
|
if (!$this->bucketExists($bucket)) {
|
|
throw new RuntimeException('Restore target bucket is missing: ' . $bucket);
|
|
}
|
|
|
|
$sourceKeys = [];
|
|
foreach (($component['objects'] ?? []) as $object) {
|
|
$source_key = (string)($object['source_key'] ?? '');
|
|
if ($source_key === '') {
|
|
continue;
|
|
}
|
|
$sourceKeys[$source_key] = true;
|
|
$ciphertext = $this->getObjectBody(self::BACKUP_BUCKET, (string)$object['storage_key']);
|
|
$body = $this->decryptString($ciphertext, $object['encryption'] ?? [], $object['aad'] ?? []);
|
|
$this->putObjectBody($bucket, $source_key, $body);
|
|
}
|
|
|
|
foreach ($this->listObjectsInBucket($bucket) as $existing) {
|
|
$key = (string)$existing['Key'];
|
|
if ($key === '' || isset($sourceKeys[$key]) || str_starts_with($key, 'restore-quarantine/')) {
|
|
continue;
|
|
}
|
|
|
|
$quarantineKey = 'restore-quarantine/' . $backup_uuid . '/' . $key;
|
|
$body = $this->getObjectBody($bucket, $key);
|
|
$this->putObjectBody($bucket, $quarantineKey, $body);
|
|
$this->deleteObject($bucket, $key);
|
|
}
|
|
}
|
|
|
|
public function pruneRetention(): array
|
|
{
|
|
$records = $this->fetchAll(
|
|
"SELECT * FROM backup_records WHERE status IN ('available', 'created_unverified') ORDER BY completed_at DESC, created_at DESC"
|
|
);
|
|
$keep = $this->retentionKeepSet($records);
|
|
$pruned = [];
|
|
|
|
foreach ($records as $record) {
|
|
$uuid = (string)$record['backup_uuid'];
|
|
if (isset($keep[$uuid])) {
|
|
continue;
|
|
}
|
|
|
|
$this->deletePrefix(self::BACKUP_BUCKET, (string)$record['storage_prefix']);
|
|
$this->query(
|
|
"UPDATE backup_records
|
|
SET status = 'pruned', expires_at = " . $this->sql(date('Y-m-d H:i:s')) . "
|
|
WHERE backup_uuid = " . $this->sql($uuid)
|
|
);
|
|
$pruned[] = $uuid;
|
|
}
|
|
|
|
return [
|
|
'pruned' => $pruned,
|
|
'kept' => array_keys($keep),
|
|
];
|
|
}
|
|
|
|
private function retentionKeepSet(array $records): array
|
|
{
|
|
$keep = [];
|
|
$newestVerifiedKept = false;
|
|
$recentCutoff = time() - ($this->configInt('retention_recent_hours', 48) * 3600);
|
|
$dailyCutoff = time() - ($this->configInt('retention_daily_days', 30) * 86400);
|
|
$weeklyCutoff = time() - ($this->configInt('retention_weekly_weeks', 8) * 7 * 86400);
|
|
$monthlyCutoff = strtotime('-' . $this->configInt('retention_monthly_months', 3) . ' months');
|
|
$daily = [];
|
|
$weekly = [];
|
|
$monthly = [];
|
|
|
|
foreach ($records as $record) {
|
|
$uuid = (string)$record['backup_uuid'];
|
|
$created = strtotime((string)($record['completed_at'] ?: $record['created_at']));
|
|
if ($created === false) {
|
|
continue;
|
|
}
|
|
|
|
if (!$newestVerifiedKept && (string)$record['status'] === 'available') {
|
|
$keep[$uuid] = true;
|
|
$newestVerifiedKept = true;
|
|
continue;
|
|
}
|
|
|
|
if ($created >= $recentCutoff) {
|
|
$keep[$uuid] = true;
|
|
continue;
|
|
}
|
|
|
|
if ($created >= $dailyCutoff) {
|
|
$day = date('Y-m-d', $created);
|
|
if (!isset($daily[$day])) {
|
|
$daily[$day] = true;
|
|
$keep[$uuid] = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($created >= $weeklyCutoff) {
|
|
$week = date('o-W', $created);
|
|
if (!isset($weekly[$week])) {
|
|
$weekly[$week] = true;
|
|
$keep[$uuid] = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ($monthlyCutoff !== false && $created >= $monthlyCutoff) {
|
|
$month = date('Y-m', $created);
|
|
if (!isset($monthly[$month])) {
|
|
$monthly[$month] = true;
|
|
$keep[$uuid] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $keep;
|
|
}
|
|
|
|
private function restorePreflightChecks(array $record, array $manifest): array
|
|
{
|
|
$checks = [];
|
|
$checks[] = [
|
|
'key' => 'status',
|
|
'status' => (string)$record['status'] === 'available' ? 'ok' : 'failed',
|
|
'message' => 'Backup must be verified and available.',
|
|
];
|
|
$checks[] = [
|
|
'key' => 'encryption_key',
|
|
'status' => $this->encryptionStatus()['available'] ? 'ok' : 'failed',
|
|
'message' => 'Backup encryption key must be available.',
|
|
];
|
|
|
|
foreach (($manifest['components'] ?? []) as $component) {
|
|
if (($component['status'] ?? '') !== 'stored') {
|
|
continue;
|
|
}
|
|
if (($component['storage_key'] ?? '') !== '') {
|
|
$checks[] = [
|
|
'key' => 'component:' . (string)$component['logical_name'],
|
|
'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['storage_key']) ? 'ok' : 'failed',
|
|
'message' => 'Component object exists: ' . (string)$component['logical_name'],
|
|
];
|
|
}
|
|
if (($component['manifest_key'] ?? '') !== '') {
|
|
$checks[] = [
|
|
'key' => 'component_manifest:' . (string)$component['logical_name'],
|
|
'status' => $this->objectExists(self::BACKUP_BUCKET, (string)$component['manifest_key']) ? 'ok' : 'failed',
|
|
'message' => 'Component manifest exists: ' . (string)$component['logical_name'],
|
|
];
|
|
}
|
|
}
|
|
|
|
return $checks;
|
|
}
|
|
|
|
private function createJob(string $type, ?string $backup_uuid, array $payload, ?int $actor_user_id, string $status = 'queued', int $progress = 0, ?string $message = null, array $result = []): int
|
|
{
|
|
$this->query(
|
|
"INSERT INTO backup_jobs
|
|
(job_type, backup_uuid, status, progress_percent, progress_message, payload_json, result_json, actor_user_id, started_at, completed_at)
|
|
VALUES ("
|
|
. $this->sql($type) . ', '
|
|
. $this->nullableSql($backup_uuid) . ', '
|
|
. $this->sql($status) . ', '
|
|
. (string)$progress . ', '
|
|
. $this->nullableSql($message) . ', '
|
|
. $this->nullableSql(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
|
|
. $this->nullableSql($result === [] ? null : json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ', '
|
|
. ($actor_user_id === null ? 'NULL' : (string)$actor_user_id) . ', '
|
|
. ($status === 'queued' ? 'NULL, NULL' : $this->sql(date('Y-m-d H:i:s')) . ', ' . $this->sql(date('Y-m-d H:i:s')))
|
|
. ")"
|
|
);
|
|
|
|
return $this->insertId();
|
|
}
|
|
|
|
private function completeJob(int $job_id, string $status, string $message, array $result, ?string $error): void
|
|
{
|
|
$this->query(
|
|
"UPDATE backup_jobs
|
|
SET status = " . $this->sql($status) . ",
|
|
progress_percent = 100,
|
|
progress_message = " . $this->sql(substr($message, 0, 255)) . ",
|
|
result_json = " . $this->nullableSql(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . ",
|
|
error_message = " . $this->nullableSql($error) . ",
|
|
completed_at = " . $this->sql(date('Y-m-d H:i:s')) . ",
|
|
lock_owner = NULL,
|
|
locked_at = NULL
|
|
WHERE id = " . (string)$job_id
|
|
);
|
|
}
|
|
|
|
private function updateJobProgressForBackup(string $backup_uuid, int $percent, string $message): void
|
|
{
|
|
$this->query(
|
|
"UPDATE backup_jobs
|
|
SET progress_percent = " . max(0, min(99, $percent)) . ",
|
|
progress_message = " . $this->sql(substr($message, 0, 255)) . "
|
|
WHERE backup_uuid = " . $this->sql($backup_uuid) . " AND status = 'running'"
|
|
);
|
|
}
|
|
|
|
private function insertComponent(string $backup_uuid, array $component): void
|
|
{
|
|
$this->query(
|
|
"INSERT INTO backup_components
|
|
(backup_uuid, component_type, logical_name, source_bucket, source_prefix, storage_key, manifest_key, object_count, byte_size, content_sha256, encrypted_sha256, encryption_key_id, status, error_message)
|
|
VALUES ("
|
|
. $this->sql($backup_uuid) . ', '
|
|
. $this->sql((string)$component['component_type']) . ', '
|
|
. $this->sql((string)$component['logical_name']) . ', '
|
|
. $this->nullableSql($component['source_bucket'] ?? null) . ', '
|
|
. $this->nullableSql($component['source_prefix'] ?? null) . ', '
|
|
. $this->nullableSql($component['storage_key'] ?? null) . ', '
|
|
. $this->nullableSql($component['manifest_key'] ?? null) . ', '
|
|
. (string)(int)($component['object_count'] ?? 0) . ', '
|
|
. (string)(int)($component['byte_size'] ?? 0) . ', '
|
|
. $this->nullableSql($component['content_sha256'] ?? null) . ', '
|
|
. $this->nullableSql($component['encrypted_sha256'] ?? null) . ', '
|
|
. $this->nullableSql($component['encryption']['key_id'] ?? null) . ', '
|
|
. $this->sql((string)($component['status'] ?? 'stored')) . ', '
|
|
. $this->nullableSql($component['error'] ?? null)
|
|
. ")"
|
|
);
|
|
}
|
|
|
|
private function clearComponents(string $backup_uuid): void
|
|
{
|
|
$this->query("DELETE FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid));
|
|
}
|
|
|
|
private function componentsForBackup(string $backup_uuid): array
|
|
{
|
|
return $this->fetchAll(
|
|
"SELECT * FROM backup_components WHERE backup_uuid = " . $this->sql($backup_uuid) . " ORDER BY id ASC"
|
|
);
|
|
}
|
|
|
|
private function requireBackupRecord(string $backup_uuid): array
|
|
{
|
|
$record = $this->backupRecord($backup_uuid);
|
|
if ($record === null) {
|
|
throw new RuntimeException('Backup not found.');
|
|
}
|
|
return $record;
|
|
}
|
|
|
|
private function backupRecord(string $backup_uuid): ?array
|
|
{
|
|
return $this->fetchOne(
|
|
"SELECT * FROM backup_records WHERE backup_uuid = " . $this->sql($backup_uuid)
|
|
);
|
|
}
|
|
|
|
private function loadManifest(array $record): array
|
|
{
|
|
$key = (string)($record['manifest_key'] ?? '');
|
|
if ($key === '') {
|
|
throw new RuntimeException('Backup manifest is missing.');
|
|
}
|
|
$body = $this->getObjectBody(self::BACKUP_BUCKET, $key);
|
|
if ((string)($record['manifest_sha256'] ?? '') !== '' && hash('sha256', $body) !== (string)$record['manifest_sha256']) {
|
|
throw new RuntimeException('Backup manifest hash mismatch.');
|
|
}
|
|
$manifest = json_decode($body, true);
|
|
if (!is_array($manifest)) {
|
|
throw new RuntimeException('Backup manifest is invalid JSON.');
|
|
}
|
|
return $manifest;
|
|
}
|
|
|
|
private function encryptString(string $plaintext, array $aad): array
|
|
{
|
|
$key = $this->currentEncryptionKey();
|
|
$nonce = random_bytes(12);
|
|
$tag = '';
|
|
$aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
|
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
|
|
if ($ciphertext === false) {
|
|
throw new RuntimeException('Could not encrypt backup component.');
|
|
}
|
|
|
|
return [
|
|
'ciphertext' => $ciphertext,
|
|
'metadata' => [
|
|
'algorithm' => 'AES-256-GCM',
|
|
'key_id' => $key['id'],
|
|
'nonce' => base64_encode($nonce),
|
|
'tag' => base64_encode($tag),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function decryptString(string $ciphertext, array $metadata, array $aad): string
|
|
{
|
|
$key = $this->currentEncryptionKey();
|
|
$nonce = base64_decode((string)($metadata['nonce'] ?? ''), true);
|
|
$tag = base64_decode((string)($metadata['tag'] ?? ''), true);
|
|
if ($nonce === false || $tag === false) {
|
|
throw new RuntimeException('Backup encryption metadata is invalid.');
|
|
}
|
|
if (isset($metadata['key_id']) && (string)$metadata['key_id'] !== $key['id']) {
|
|
throw new RuntimeException('Configured backup encryption key id does not match artifact key id.');
|
|
}
|
|
|
|
$aadJson = json_encode($aad, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '';
|
|
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key['key'], OPENSSL_RAW_DATA, $nonce, $tag, $aadJson);
|
|
if ($plaintext === false) {
|
|
throw new RuntimeException('Could not decrypt backup component.');
|
|
}
|
|
return $plaintext;
|
|
}
|
|
|
|
private function currentEncryptionKey(): array
|
|
{
|
|
$raw = $this->readEnv('BACKUP_ENCRYPTION_KEY_V1');
|
|
if ($raw === '') {
|
|
throw new RuntimeException('BACKUP_ENCRYPTION_KEY_V1 is not configured.');
|
|
}
|
|
|
|
$key = base64_decode($raw, true);
|
|
if ($key === false || strlen($key) !== 32) {
|
|
$hex = ctype_xdigit($raw) ? hex2bin($raw) : false;
|
|
$key = is_string($hex) && strlen($hex) === 32 ? $hex : hash('sha256', $raw, true);
|
|
}
|
|
|
|
if (strlen($key) !== 32) {
|
|
throw new RuntimeException('Backup encryption key must resolve to 32 bytes.');
|
|
}
|
|
|
|
$id = $this->readEnv('BACKUP_ENCRYPTION_KEY_ID');
|
|
if ($id === '') {
|
|
$id = 'env-v1-' . substr(hash('sha256', $key), 0, 12);
|
|
}
|
|
|
|
return ['id' => $id, 'key' => $key];
|
|
}
|
|
|
|
private function encryptionStatus(): array
|
|
{
|
|
try {
|
|
$key = $this->currentEncryptionKey();
|
|
return ['available' => true, 'key_id' => $key['id']];
|
|
} catch (Throwable $throwable) {
|
|
return ['available' => false, 'error' => $throwable->getMessage()];
|
|
}
|
|
}
|
|
|
|
private function verificationDatabaseConfig(): ?array
|
|
{
|
|
$host = $this->readEnv('BACKUP_VERIFY_DB_HOST');
|
|
$user = $this->readEnv('BACKUP_VERIFY_DB_USER');
|
|
$database = $this->readEnv('BACKUP_VERIFY_DB_DATABASE');
|
|
if ($host === '' || $user === '' || $database === '') {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'host' => $host,
|
|
'user' => $user,
|
|
'password' => $this->readEnv('BACKUP_VERIFY_DB_PASSWORD'),
|
|
'database' => $database,
|
|
'port' => (int)($this->readEnv('BACKUP_VERIFY_DB_PORT') ?: '3306'),
|
|
];
|
|
}
|
|
|
|
private function importSqlIntoDatabase(string $sql, array $config, string $label): void
|
|
{
|
|
$dir = self::local_backup_path . 'restore-' . bin2hex(random_bytes(6));
|
|
$this->ensureLocalDirectory($dir);
|
|
$path = $dir . '/database.sql';
|
|
file_put_contents($path, $sql);
|
|
|
|
$host = escapeshellarg((string)$config['host']);
|
|
$user = escapeshellarg((string)$config['user']);
|
|
$database = escapeshellarg((string)$config['database']);
|
|
$port = (int)($config['port'] ?? 3306);
|
|
$command = "mysql -h $host -P $port -u $user $database";
|
|
$environment = array_merge(getenv() ?: [], $_ENV);
|
|
$environment['MYSQL_PWD'] = (string)($config['password'] ?? '');
|
|
$descriptors = [
|
|
0 => ['file', $path, 'r'],
|
|
1 => ['pipe', 'w'],
|
|
2 => ['pipe', 'w'],
|
|
];
|
|
|
|
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
|
if (!is_resource($process)) {
|
|
$this->removeDirectory($dir);
|
|
throw new RuntimeException('Could not start mysql import for backup ' . $label . '.');
|
|
}
|
|
|
|
$stdout = stream_get_contents($pipes[1]);
|
|
fclose($pipes[1]);
|
|
$stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[2]);
|
|
$return = proc_close($process);
|
|
$this->removeDirectory($dir);
|
|
|
|
if ($return !== 0) {
|
|
throw new RuntimeException('Database import for backup ' . $label . ' failed: ' . trim((string)($stderr ?: $stdout)));
|
|
}
|
|
}
|
|
|
|
private function putObjectBody(string $bucket, string $key, string $body, string $content_type = 'application/octet-stream'): void
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
$path = $this->localTestObjectPath($bucket, $key);
|
|
$this->ensureLocalDirectory(dirname($path));
|
|
file_put_contents($path, $body);
|
|
return;
|
|
}
|
|
|
|
self::getS3Client()->putObject([
|
|
'Bucket' => $bucket,
|
|
'Key' => $key,
|
|
'Body' => $body,
|
|
'ContentType' => $content_type,
|
|
]);
|
|
}
|
|
|
|
private function getObjectBody(string $bucket, string $key): string
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
$path = $this->localTestObjectPath($bucket, $key);
|
|
if (!is_file($path)) {
|
|
throw new RuntimeException('Object does not exist: ' . $bucket . '/' . $key);
|
|
}
|
|
$body = file_get_contents($path);
|
|
if ($body === false) {
|
|
throw new RuntimeException('Could not read object: ' . $bucket . '/' . $key);
|
|
}
|
|
return $body;
|
|
}
|
|
|
|
$result = self::getS3Client()->getObject([
|
|
'Bucket' => $bucket,
|
|
'Key' => $key,
|
|
]);
|
|
return (string)$result['Body'];
|
|
}
|
|
|
|
private function objectExists(string $bucket, string $key): bool
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
return is_file($this->localTestObjectPath($bucket, $key));
|
|
}
|
|
|
|
return self::getS3Client()->doesObjectExist($bucket, $key);
|
|
}
|
|
|
|
private function bucketExists(string $bucket): bool
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
return is_dir($this->localTestBucketDirectory($bucket));
|
|
}
|
|
|
|
return self::getS3Client()->doesBucketExist($bucket);
|
|
}
|
|
|
|
private function listObjectsInBucket(string $bucket, string $prefix = ''): array
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
$dir = $this->localTestBucketDirectory($bucket);
|
|
if (!is_dir($dir)) {
|
|
return [];
|
|
}
|
|
|
|
$objects = [];
|
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS));
|
|
foreach ($iterator as $fileInfo) {
|
|
if (!$fileInfo->isFile()) {
|
|
continue;
|
|
}
|
|
$relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen($dir) + 1));
|
|
if ($prefix !== '' && !str_starts_with($relative, $prefix)) {
|
|
continue;
|
|
}
|
|
$objects[] = ['Key' => $relative, 'Size' => $fileInfo->getSize()];
|
|
}
|
|
return $objects;
|
|
}
|
|
|
|
$objects = [];
|
|
$continuation = null;
|
|
do {
|
|
$params = ['Bucket' => $bucket, 'Prefix' => $prefix];
|
|
if ($continuation !== null) {
|
|
$params['ContinuationToken'] = $continuation;
|
|
}
|
|
$result = self::getS3Client()->listObjectsV2($params);
|
|
foreach (($result['Contents'] ?? []) as $object) {
|
|
$objects[] = $object;
|
|
}
|
|
$continuation = !empty($result['IsTruncated']) ? (string)($result['NextContinuationToken'] ?? '') : null;
|
|
} while ($continuation !== null && $continuation !== '');
|
|
|
|
return $objects;
|
|
}
|
|
|
|
private function deleteObject(string $bucket, string $key): void
|
|
{
|
|
if ($this->shouldUseLocalBackupStorage()) {
|
|
$path = $this->localTestObjectPath($bucket, $key);
|
|
if (is_file($path)) {
|
|
@unlink($path);
|
|
}
|
|
return;
|
|
}
|
|
|
|
self::getS3Client()->deleteObject(['Bucket' => $bucket, 'Key' => $key]);
|
|
}
|
|
|
|
private function deletePrefix(string $bucket, string $prefix): void
|
|
{
|
|
foreach ($this->listObjectsInBucket($bucket, $prefix) as $object) {
|
|
$this->deleteObject($bucket, (string)$object['Key']);
|
|
}
|
|
}
|
|
|
|
private function shouldUseLocalBackupStorage(): bool
|
|
{
|
|
return getenv('RUN_API_TESTS') === '1'
|
|
&& (trim((string)$this->getEndpoint()) === ''
|
|
|| trim((string)$this->getAccessKey()) === ''
|
|
|| trim((string)$this->getSecretKey()) === '');
|
|
}
|
|
|
|
private function localTestBucketDirectory(string $bucket): string
|
|
{
|
|
$bucket = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $bucket) ?: 'default';
|
|
$directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
|
. DIRECTORY_SEPARATOR
|
|
. 'truckwash-test-object-store'
|
|
. DIRECTORY_SEPARATOR
|
|
. $bucket;
|
|
if (!is_dir($directory)) {
|
|
mkdir($directory, 0777, true);
|
|
}
|
|
return $directory;
|
|
}
|
|
|
|
private function localTestObjectPath(string $bucket, string $key): string
|
|
{
|
|
$normalizedKey = str_replace('\\', '/', $key);
|
|
$normalizedKey = preg_replace('#(^|/)\\.\\.(?=/|$)#', '', $normalizedKey) ?? $normalizedKey;
|
|
$normalizedKey = ltrim($normalizedKey, '/');
|
|
if ($normalizedKey === '') {
|
|
$normalizedKey = 'object';
|
|
}
|
|
return $this->localTestBucketDirectory($bucket) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalizedKey);
|
|
}
|
|
|
|
private function legacyBackups(): array
|
|
{
|
|
$legacy = [];
|
|
try {
|
|
foreach ($this->listObjectsInBucket(self::BACKUP_BUCKET, self::metadata_s3_prefix) as $object) {
|
|
$key = (string)$object['Key'];
|
|
if (!str_ends_with($key, '.json')) {
|
|
continue;
|
|
}
|
|
$uuid = str_replace([self::metadata_s3_prefix . 'backup_', '.json'], '', $key);
|
|
if ($this->backupRecord($uuid) !== null) {
|
|
continue;
|
|
}
|
|
$metadata = json_decode($this->getObjectBody(self::BACKUP_BUCKET, $key), true);
|
|
if (is_array($metadata)) {
|
|
$legacy[] = $metadata + ['backup_uuid' => $uuid, 'status' => 'legacy_unverified'];
|
|
}
|
|
}
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
return $legacy;
|
|
}
|
|
|
|
private function appIdentifier(): array
|
|
{
|
|
return [
|
|
'host' => gethostname(),
|
|
'wd' => defined('WD') ? WD : null,
|
|
'git_sha' => $this->readEnv('GIT_COMMIT_SHA') ?: $this->readEnv('SOURCE_COMMIT'),
|
|
];
|
|
}
|
|
|
|
private function sanitizedRuntimeConfigKeys(): array
|
|
{
|
|
$keys = array_keys($_ENV + $_SERVER);
|
|
$required = array_values(array_filter($keys, static function (string $key): bool {
|
|
return str_starts_with($key, 'CONFIG_DB_')
|
|
|| str_starts_with($key, 'MINIO_')
|
|
|| str_starts_with($key, 'BACKUP_');
|
|
}));
|
|
sort($required, SORT_STRING);
|
|
return $required;
|
|
}
|
|
|
|
private function configBool(string $variable, bool $default): bool
|
|
{
|
|
$config = $this->configMap();
|
|
if (!array_key_exists($variable, $config)) {
|
|
return $default;
|
|
}
|
|
|
|
$value = $config[$variable];
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
if (is_int($value) || is_float($value)) {
|
|
return (int)$value !== 0;
|
|
}
|
|
if (is_string($value)) {
|
|
$normalized = strtolower(trim($value));
|
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
|
return true;
|
|
}
|
|
if (in_array($normalized, ['0', 'false', 'no', 'off', ''], true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
private function configInt(string $variable, int $default): int
|
|
{
|
|
$config = $this->configMap();
|
|
if (!array_key_exists($variable, $config) || !is_numeric($config[$variable])) {
|
|
return $default;
|
|
}
|
|
return max(0, (int)$config[$variable]);
|
|
}
|
|
|
|
private function configMap(): array
|
|
{
|
|
$map = [];
|
|
foreach ($this->config->getConfig() as $row) {
|
|
$map[(string)$row['variable']] = $row['value'];
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
private function encodeObjectKey(string $key): string
|
|
{
|
|
return rtrim(strtr(base64_encode($key), '+/', '-_'), '=');
|
|
}
|
|
|
|
private function readEnv(string $key): string
|
|
{
|
|
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
|
if ($value === false || $value === null) {
|
|
return '';
|
|
}
|
|
return trim((string)$value);
|
|
}
|
|
|
|
private static function uuid(): string
|
|
{
|
|
return bin2hex(random_bytes(16));
|
|
}
|
|
|
|
private function ensureLocalDirectory(string $path): void
|
|
{
|
|
if (!is_dir($path) && !mkdir($path, 0770, true) && !is_dir($path)) {
|
|
throw new RuntimeException('Could not create directory: ' . $path);
|
|
}
|
|
}
|
|
|
|
private function removeDirectory(string $path): void
|
|
{
|
|
if (!is_dir($path)) {
|
|
return;
|
|
}
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
foreach ($iterator as $fileInfo) {
|
|
if ($fileInfo->isDir()) {
|
|
@rmdir($fileInfo->getPathname());
|
|
} else {
|
|
@unlink($fileInfo->getPathname());
|
|
}
|
|
}
|
|
@rmdir($path);
|
|
}
|
|
|
|
private function query(string $sql): void
|
|
{
|
|
$this->db()->query($sql);
|
|
}
|
|
|
|
private function fetchOne(string $sql): ?array
|
|
{
|
|
$result = $this->db()->query($sql);
|
|
if (!is_object($result)) {
|
|
return null;
|
|
}
|
|
$row = $result->fetch_assoc();
|
|
return is_array($row) ? $row : null;
|
|
}
|
|
|
|
private function fetchAll(string $sql): array
|
|
{
|
|
$result = $this->db()->query($sql);
|
|
if (!is_object($result)) {
|
|
return [];
|
|
}
|
|
return $result->fetch_all(MYSQLI_ASSOC);
|
|
}
|
|
|
|
private function affectedRows(): int
|
|
{
|
|
return (int)$this->db()->conn()->affected_rows;
|
|
}
|
|
|
|
private function insertId(): int
|
|
{
|
|
return (int)$this->db()->insert_id();
|
|
}
|
|
|
|
private function db(): db
|
|
{
|
|
global $db;
|
|
if (!$db instanceof db) {
|
|
throw new RuntimeException('Database connection is not available.');
|
|
}
|
|
return $db;
|
|
}
|
|
|
|
private function sql(string|int|float|bool|null $value): string
|
|
{
|
|
return "'" . $this->db()->escape_string((string)$value) . "'";
|
|
}
|
|
|
|
private function nullableSql(mixed $value): string
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return 'NULL';
|
|
}
|
|
return $this->sql((string)$value);
|
|
}
|
|
|
|
private function decodeJson(mixed $value): array
|
|
{
|
|
if (!is_string($value) || trim($value) === '') {
|
|
return [];
|
|
}
|
|
$decoded = json_decode($value, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
}
|