diff --git a/.env.prod.example b/.env.prod.example index 9e72b22..af21fee 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -16,6 +16,15 @@ CONTROL_BIND=127.0.0.1:8080 # [required] Unique high-entropy secrets owned by the named database/service. CONTROL_DB_PASSWORD=replace-with-a-unique-high-entropy-control-db-password REDIS_PASSWORD=replace-with-a-unique-high-entropy-valkey-password +# [required for control and telemetry profiles] Absolute path to a mode-0600 file containing one random metrics bearer token. +METRICS_TOKEN_FILE=/etc/cdnfoundry/secrets/metrics-token + +# [required] Encrypted off-host Restic repository. S3 credentials should permit only this backup prefix. +RESTIC_REPOSITORY=s3:https://s3.example.com/cdnfoundry-control +RESTIC_PASSWORD_FILE=/etc/cdnfoundry/secrets/restic-password +BACKUP_ACCESS_KEY_ID=replace-with-backup-only-access-key +BACKUP_SECRET_ACCESS_KEY=replace-with-backup-only-secret-key +BACKUP_DEFAULT_REGION=us-east-1 # [required] ACME account contact. Expiry and validation notices go to this address and the admin panel. ACME_CONTACT_EMAIL=cdn-operations@example.com diff --git a/Makefile b/Makefile index 57f8b55..0f456dd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ COMPOSE_DEV := docker compose -f compose.dev.yml COMPOSE_PROD := docker compose --env-file .env.prod -f compose.prod.yml COMPOSE_PROD_EXAMPLE := docker compose --env-file .env.prod.example -f compose.prod.yml -.PHONY: dev-assets dev-up dev-edge-up dev-edge-status dev-scale-up dev-down dev-migrate dev-pdns-migrate dev-test dev-e2e dev-phase7-e2e dev-scale-e2e dev-logs prod-pull prod-migrate prod-pdns-migrate prod-control prod-dns prod-telemetry prod-edge config-check openapi-check docs-check +.PHONY: dev-assets dev-up dev-edge-up dev-edge-status dev-scale-up dev-down dev-migrate dev-pdns-migrate dev-test dev-e2e dev-phase7-e2e dev-phase8-e2e dev-phase8-recovery-e2e dev-phase8-upgrade-e2e dev-phase8-throughput-e2e dev-phase8-mmdb-e2e dev-scale-e2e dev-logs prod-pull prod-migrate prod-pdns-migrate prod-control prod-dns prod-telemetry prod-edge config-check openapi-check docs-check dev-assets: docker build --target frontend-assets-export --output type=local,dest=./core/public/build ./core @@ -41,6 +41,7 @@ dev-e2e: python3 tests/e2e/phase5_tls.py python3 tests/e2e/phase6_security.py python3 tests/e2e/phase7_analytics.py + python3 tests/e2e/phase8_operations.py python3 tests/e2e/phase4_runtime.py dev-scale-e2e: @@ -49,6 +50,21 @@ dev-scale-e2e: dev-phase7-e2e: python3 tests/e2e/phase7_analytics.py +dev-phase8-e2e: + python3 tests/e2e/phase8_operations.py + +dev-phase8-recovery-e2e: + python3 tests/e2e/phase8_recovery.py + +dev-phase8-upgrade-e2e: + python3 tests/e2e/phase8_upgrade.py + +dev-phase8-throughput-e2e: + python3 tests/e2e/phase8_throughput.py + +dev-phase8-mmdb-e2e: + python3 tests/e2e/phase8_mmdb.py + dev-logs: $(COMPOSE_DEV) logs -f --tail=200 diff --git a/compose.dev.yml b/compose.dev.yml index 60ebf19..1e0f718 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -28,6 +28,10 @@ x-core-env: &core-env CLICKHOUSE_DATABASE: cdnf CLICKHOUSE_USER: cdnf CLICKHOUSE_PASSWORD: cdnf-dev-only + PROMETHEUS_URL: http://prometheus:9090 + METRICS_TOKEN_FILE: /run/dev-metrics/metrics-token + RESTIC_REPOSITORY: /app/storage/app/restic + RESTIC_PASSWORD_FILE: /run/dev-backup/restic-password x-core: &core build: @@ -45,6 +49,8 @@ x-core: &core - core-bootstrap-cache:/app/bootstrap/cache - mmdb:/mmdb:ro - dev-pki:/run/dev-pki:ro + - ./docker/prometheus/dev-metrics-token:/run/dev-metrics/metrics-token:ro + - ./docker/backup/dev-restic-password:/run/dev-backup/restic-password:ro depends_on: dev-pki: { condition: service_completed_successfully } vendor-init: { condition: service_completed_successfully } @@ -213,6 +219,11 @@ services: pdns-auth: { condition: service_healthy } networks: [dns] restart: unless-stopped + healthcheck: + test: [CMD, python3, -c, "import urllib.request; assert urllib.request.urlopen('http://127.0.0.1:8083/metrics', timeout=2).read(1)"] + interval: 10s + timeout: 3s + retries: 10 pebble: image: ghcr.io/letsencrypt/pebble@sha256:ddf230642b1a584f519f32e347de1b05a6e4c1f6c35c1863b33effeab5f78199 @@ -261,8 +272,22 @@ services: volumes: - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./docker/prometheus/telemetry-alerts.yml:/etc/prometheus/telemetry-alerts.yml:ro + - ./docker/prometheus/alerts.test.yml:/etc/prometheus/alerts.test.yml:ro + - ./docker/prometheus/dev-metrics-token:/run/secrets/metrics-token:ro - prometheus:/prometheus + networks: [telemetry, control, dns] + + node-exporter: + image: prom/node-exporter:v1.10.2 + command: [--path.procfs=/host/proc, --path.sysfs=/host/sys, --path.rootfs=/host/root, --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)] + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro,rslave networks: [telemetry] + read_only: true + restart: unless-stopped alertmanager: image: prom/alertmanager:v0.32.1 @@ -272,7 +297,7 @@ services: origin-http: image: nginx:1.30.3-alpine - command: [sh, -c, "dd if=/dev/zero of=/tmp/large-object bs=1048576 count=2 >/dev/null 2>&1 && exec nginx -g 'daemon off;'"] + command: [sh, -c, "dd if=/dev/zero of=/tmp/large-object bs=1048576 count=2 >/dev/null 2>&1 && dd if=/dev/zero of=/tmp/graceful-object bs=8192 count=1 >/dev/null 2>&1 && exec nginx -g 'daemon off;'"] tmpfs: [/tmp:size=4m] volumes: [./docker/nginx/origin.conf:/etc/nginx/conf.d/default.conf:ro] networks: [edge] diff --git a/compose.prod.yml b/compose.prod.yml index 215c477..c9e7067 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -30,6 +30,13 @@ x-core-env: &core-env CLICKHOUSE_DATABASE: cdnf CLICKHOUSE_USER: cdnf CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD is required} + PROMETHEUS_URL: http://prometheus:9090 + METRICS_TOKEN_FILE: /run/secrets/metrics-token + RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:?RESTIC_REPOSITORY is required} + RESTIC_PASSWORD_FILE: /run/secrets/restic-password + BACKUP_ACCESS_KEY_ID: ${BACKUP_ACCESS_KEY_ID:?BACKUP_ACCESS_KEY_ID is required} + BACKUP_SECRET_ACCESS_KEY: ${BACKUP_SECRET_ACCESS_KEY:?BACKUP_SECRET_ACCESS_KEY is required} + BACKUP_DEFAULT_REGION: ${BACKUP_DEFAULT_REGION:-us-east-1} x-core: &core image: ghcr.io/vaheed/cdnfoundry-core:${CDNF_RELEASE:?CDNF_RELEASE must be an immutable commit SHA} @@ -39,6 +46,7 @@ x-core: &core redis: { condition: service_healthy } networks: [control, telemetry] restart: unless-stopped + stop_grace_period: 60s read_only: true tmpfs: [/tmp] volumes: @@ -46,6 +54,8 @@ x-core: &core - mmdb:/mmdb:ro - ${EDGE_IDENTITY_CA_CERTIFICATE:?EDGE_IDENTITY_CA_CERTIFICATE is required}:/run/secrets/edge-identity-ca.crt:ro - ${EDGE_IDENTITY_CA_PRIVATE_KEY:?EDGE_IDENTITY_CA_PRIVATE_KEY is required}:/run/secrets/edge-identity-ca.key:ro + - ${METRICS_TOKEN_FILE:?METRICS_TOKEN_FILE is required}:/run/secrets/metrics-token:ro + - ${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE is required}:/run/secrets/restic-password:ro services: core: @@ -65,6 +75,8 @@ services: core: { condition: service_healthy } networks: [control, ingress] restart: unless-stopped + stop_signal: SIGQUIT + stop_grace_period: 30s healthcheck: test: [CMD, wget, -qO-, http://127.0.0.1:8080/api/health] interval: 10s @@ -83,6 +95,8 @@ services: core: { condition: service_healthy } networks: [control, ingress] restart: unless-stopped + stop_signal: SIGQUIT + stop_grace_period: 30s read_only: true tmpfs: [/var/cache/nginx, /var/run] @@ -90,11 +104,13 @@ services: <<: *core profiles: [control] command: [php, artisan, horizon] + stop_grace_period: 120s scheduler: <<: *core profiles: [control] command: [php, artisan, schedule:work] + stop_grace_period: 30s migrate: <<: *core @@ -192,6 +208,12 @@ services: pdns-auth: { condition: service_healthy } networks: [dns-private] restart: unless-stopped + stop_grace_period: 30s + healthcheck: + test: [CMD, python3, -c, "import urllib.request; assert urllib.request.urlopen('http://127.0.0.1:8083/metrics', timeout=2).read(1)"] + interval: 10s + timeout: 3s + retries: 10 clickhouse: image: clickhouse/clickhouse-server:26.3.12.3-alpine @@ -226,8 +248,22 @@ services: volumes: - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./docker/prometheus/telemetry-alerts.yml:/etc/prometheus/telemetry-alerts.yml:ro + - ${METRICS_TOKEN_FILE:?METRICS_TOKEN_FILE is required}:/run/secrets/metrics-token:ro - prometheus:/prometheus + networks: [telemetry, control, dns-private] + restart: unless-stopped + + node-exporter: + image: prom/node-exporter:v1.10.2 + profiles: [telemetry] + command: [--path.procfs=/host/proc, --path.sysfs=/host/sys, --path.rootfs=/host/root, --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)] + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro,rslave networks: [telemetry] + read_only: true restart: unless-stopped alertmanager: @@ -262,6 +298,8 @@ services: - /usr/local/openresty/nginx/logs:rw,noexec,nosuid,size=16m networks: [edge, telemetry] restart: unless-stopped + stop_signal: SIGQUIT + stop_grace_period: 30s mem_limit: 2g cpus: 2 pids_limit: 256 @@ -299,6 +337,8 @@ services: - /usr/local/openresty/nginx/logs:rw,noexec,nosuid,size=16m networks: [edge, telemetry] restart: unless-stopped + stop_signal: SIGQUIT + stop_grace_period: 30s mem_limit: 512m cpus: 0.5 pids_limit: 128 @@ -329,6 +369,7 @@ services: - ${EDGE_CONTROL_CA_CERTIFICATE:?EDGE_CONTROL_CA_CERTIFICATE is required}:/run/secrets/edge-control-ca.crt:ro networks: [edge] restart: unless-stopped + stop_grace_period: 30s read_only: true tmpfs: [/tmp] mem_limit: 128m diff --git a/core/Dockerfile b/core/Dockerfile index 144aa4a..d01a666 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -1,6 +1,6 @@ FROM php:8.5-fpm-alpine AS php-dependencies -RUN apk add --no-cache icu-libs libpq libzip libmaxminddb nginx supervisor su-exec \ +RUN apk add --no-cache icu-libs libpq libzip libmaxminddb nginx supervisor su-exec postgresql-client restic \ && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev postgresql-dev libzip-dev \ && docker-php-ext-install intl pcntl pdo_pgsql zip \ && apk del .build-deps @@ -40,7 +40,9 @@ RUN composer dump-autoload --no-dev --classmap-authoritative \ && chown -R www-data:www-data storage bootstrap/cache COPY docker/php/entrypoint.sh /usr/local/bin/cdnf-entrypoint -RUN chmod +x /usr/local/bin/cdnf-entrypoint +COPY docker/backup/create.sh /usr/local/bin/cdnf-backup-create +COPY docker/backup/restore.sh /usr/local/bin/cdnf-backup-restore +RUN chmod +x /usr/local/bin/cdnf-entrypoint /usr/local/bin/cdnf-backup-create /usr/local/bin/cdnf-backup-restore ENTRYPOINT ["cdnf-entrypoint"] CMD ["php-fpm", "-F"] diff --git a/core/app/Console/Commands/CreateControlBackup.php b/core/app/Console/Commands/CreateControlBackup.php new file mode 100644 index 0000000..573ad3b --- /dev/null +++ b/core/app/Console/Commands/CreateControlBackup.php @@ -0,0 +1,36 @@ +configured()) { + $this->error('Encrypted off-host backup repository is not configured.'); + + return self::FAILURE; + } + $backup = Backup::query()->create(['status' => 'pending']); + $operation = Operation::query()->create(['type' => 'backup.create', 'status' => 'pending', 'input' => ['backup_id' => $backup->id]]); + if ($this->option('wait')) { + CreateControlBackupJob::dispatchSync($backup->id, $operation->id); + } else { + CreateControlBackupJob::dispatch($backup->id, $operation->id); + } + $backup->refresh(); + $this->line(json_encode(['backup_id' => $backup->id, 'operation_id' => $operation->id, 'status' => $backup->status, 'snapshot_id' => $backup->snapshot_id, 'verified_at' => $backup->verified_at?->toIso8601String()], JSON_THROW_ON_ERROR)); + + return $backup->status === 'failed' ? self::FAILURE : self::SUCCESS; + } +} diff --git a/core/app/Console/Commands/GenerateOpenApi.php b/core/app/Console/Commands/GenerateOpenApi.php index b5cd50d..36aebc3 100644 --- a/core/app/Console/Commands/GenerateOpenApi.php +++ b/core/app/Console/Commands/GenerateOpenApi.php @@ -165,9 +165,10 @@ private function pathParameters(string $path): array return collect($matches[1])->map(function (string $name): array { $schema = match ($name) { - 'operation', 'edge', 'purge' => ['type' => 'string', 'format' => 'uuid'], + 'operation', 'edge', 'purge', 'backup', 'job' => ['type' => 'string', 'format' => 'uuid'], 'checksum' => ['type' => 'string', 'pattern' => '^[a-f0-9]{64}$'], 'group' => ['type' => 'string', 'enum' => array_keys(config('platform.groups', []))], + 'scope' => ['type' => 'string', 'enum' => ['dns', 'edges', 'tls', 'purges', 'usage']], default => ['type' => 'integer', 'minimum' => 1], }; diff --git a/core/app/Console/Commands/PruneAuditLogs.php b/core/app/Console/Commands/PruneAuditLogs.php new file mode 100644 index 0000000..e411cbf --- /dev/null +++ b/core/app/Console/Commands/PruneAuditLogs.php @@ -0,0 +1,24 @@ +option('batch'))); + $ids = AuditLog::query()->where('created_at', '<', now()->subDays($settings->integer('operations', 'audit_retention_days')))->orderBy('id')->limit($batch)->pluck('id'); + $deleted = $ids->isEmpty() ? 0 : AuditLog::query()->whereIn('id', $ids)->delete(); + $this->info("Deleted {$deleted} expired audit events."); + + return self::SUCCESS; + } +} diff --git a/core/app/Console/Commands/RestoreControlBackup.php b/core/app/Console/Commands/RestoreControlBackup.php new file mode 100644 index 0000000..e141b7e --- /dev/null +++ b/core/app/Console/Commands/RestoreControlBackup.php @@ -0,0 +1,76 @@ +error('Set BACKUP_RESTORE_ALLOWED=true only in the one-off maintenance container.'); + + return self::FAILURE; + } + $operation = Operation::query()->whereKey($this->argument('operation'))->where('type', 'backup.restore')->where('status', 'running')->first(); + if ($operation === null || ($operation->result['preflight'] ?? null) !== 'passed') { + $this->error('A successful restore preflight operation is required.'); + + return self::FAILURE; + } + $backup = Backup::query()->whereKey($operation->input['backup_id'])->where('status', 'succeeded')->firstOrFail(); + Artisan::call('down', ['--retry' => 60]); + try { + $repository->restore($backup->snapshot_id); + Artisan::call('migrate', ['--force' => true]); + $restoredBackup = Backup::query()->find($backup->id) ?? Backup::query()->create(['id' => $backup->id]); + $restoredBackup->update(['status' => 'succeeded', 'snapshot_id' => $backup->snapshot_id, 'verified_at' => now(), 'last_error' => null]); + $receipt = Operation::query()->find($operation->id) ?? Operation::query()->create(['id' => $operation->id, 'type' => 'backup.restore', 'status' => 'running', 'input' => ['backup_id' => $backup->id]]); + $receipt->update(['status' => 'succeeded', 'result' => ['backup_id' => $backup->id, 'snapshot_id' => $backup->snapshot_id, 'restored_at' => now()->toIso8601String()], 'finished_at' => now()]); + AuditLog::record(null, 'backup.restore_completed', $restoredBackup, ['operation_id' => $receipt->id, 'snapshot_id' => $backup->snapshot_id]); + ReconcileAllDnsZones::dispatch(Operation::query()->create(['type' => 'dns.global_reconcile', 'status' => 'pending', 'input' => []])->id); + ReconcileAllEdgeDomains::dispatch(Operation::query()->create(['type' => 'edges.global_reconcile', 'status' => 'pending', 'input' => []])->id); + ReconcileAllTls::dispatch(Operation::query()->create(['type' => 'tls.global_reconcile', 'status' => 'pending', 'input' => []])->id); + ReconcileAllPurges::dispatch(Operation::query()->create(['type' => 'purges.global_reconcile', 'status' => 'pending', 'input' => []])->id); + $from = now()->utc()->subHour()->startOfHour(); + $to = now()->utc()->startOfHour(); + $usage = Operation::query()->create(['type' => 'usage.global_reconcile', 'status' => 'pending', 'input' => ['from' => $from->toIso8601String(), 'to' => $to->toIso8601String()]]); + BuildUsageRollups::dispatch($from->toIso8601String(), $to->toIso8601String(), null, $usage->id); + Artisan::call('up'); + $this->info('Restore completed; reconciliation has been queued.'); + + return self::SUCCESS; + } catch (Throwable $exception) { + try { + Operation::query()->whereKey($operation->id)->update([ + 'status' => 'failed', + 'error' => mb_substr($exception->getMessage(), 0, 4000), + 'finished_at' => now(), + ]); + AuditLog::record(null, 'backup.restore_failed', $backup, ['operation_id' => $operation->id]); + } catch (Throwable) { + // The restore may have failed while replacing PostgreSQL itself. + } + $this->error('Restore failed; keep maintenance mode active and inspect the recovery host logs.'); + report($exception); + + return self::FAILURE; + } + } +} diff --git a/core/app/Filament/Admin/Pages/AdminDashboard.php b/core/app/Filament/Admin/Pages/AdminDashboard.php index 5bb0e2e..2448fbc 100644 --- a/core/app/Filament/Admin/Pages/AdminDashboard.php +++ b/core/app/Filament/Admin/Pages/AdminDashboard.php @@ -13,6 +13,7 @@ use App\Models\Edge; use App\Models\Operation; use App\Models\User; +use App\Support\SystemHealth; use Filament\Pages\Dashboard; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Redis; @@ -97,6 +98,15 @@ public function getQuickLinksProperty(): array ]; } + public function getComponentStateProperty(): array + { + try { + return collect(app(SystemHealth::class)->components())->map(fn (array $state, string $name): array => ['name' => str($name)->replace('_', ' ')->headline()->toString(), ...$state])->values()->all(); + } catch (Throwable) { + return [['name' => 'Operational health', 'status' => 'unavailable', 'checked_at' => now()->toIso8601String(), 'details' => []]]; + } + } + private function queueAge(?int $pushedAt, int $depth): string { if ($depth === 0) { diff --git a/core/app/Http/Controllers/Admin/BackupController.php b/core/app/Http/Controllers/Admin/BackupController.php new file mode 100644 index 0000000..a5f53d5 --- /dev/null +++ b/core/app/Http/Controllers/Admin/BackupController.php @@ -0,0 +1,70 @@ +orderBy('id')->cursorPaginate(50)); + } + + public function show(Backup $backup): JsonResource + { + return JsonResource::make($backup); + } + + public function store(Request $request, ResticBackupRepository $repository): JsonResponse + { + abort_unless($repository->configured(), 503, 'Encrypted off-host backup repository is not configured.'); + [$backup, $operation] = DB::transaction(function () use ($request): array { + $backup = Backup::query()->create(['requested_by' => $request->user()->id, 'status' => 'pending']); + $operation = Operation::query()->create(['actor_id' => $request->user()->id, 'type' => 'backup.create', 'status' => 'pending', 'input' => ['backup_id' => $backup->id]]); + AuditLog::record($request->user(), 'backup.create_requested', $backup, ['operation_id' => $operation->id], $request->ip()); + + return [$backup, $operation]; + }); + CreateControlBackup::dispatch($backup->id, $operation->id)->afterCommit(); + + return response()->json(['data' => ['backup_id' => $backup->id, 'operation_id' => $operation->id, 'status' => 'pending']], 202); + } + + public function restore(Request $request, Backup $backup): JsonResponse + { + $data = $request->validate(['confirmation' => ['required', 'string', 'max:100'], 'current_password' => ['required', 'string', 'max:200']]); + abort_unless(hash_equals("RESTORE {$backup->id}", $data['confirmation']), 422, 'The restore confirmation value is incorrect.'); + abort_unless(Hash::check($data['current_password'], $request->user()->password), 422, 'Administrator re-authentication failed.'); + abort_unless($backup->status === 'succeeded' && $backup->snapshot_id !== null, 409, 'Only a completed backup can be restored.'); + $operation = Operation::query()->create(['actor_id' => $request->user()->id, 'type' => 'backup.restore', 'status' => 'pending', 'input' => ['backup_id' => $backup->id]]); + AuditLog::record($request->user(), 'backup.restore_preflight_requested', $backup, ['operation_id' => $operation->id], $request->ip()); + PreflightBackupRestore::dispatch($backup->id, $operation->id)->afterCommit(); + + return response()->json(['data' => ['operation_id' => $operation->id, 'status' => 'pending']], 202); + } + + public function destroy(Request $request, Backup $backup): JsonResponse + { + abort_if($backup->status === 'running', 409, 'A running backup cannot be deleted.'); + $operation = Operation::query()->create(['actor_id' => $request->user()->id, 'type' => 'backup.delete', 'status' => 'pending', 'input' => ['backup_id' => $backup->id]]); + $backup->update(['status' => 'deleting']); + AuditLog::record($request->user(), 'backup.delete_requested', $backup, ['operation_id' => $operation->id], $request->ip()); + DeleteControlBackup::dispatch($backup->id, $operation->id)->afterCommit(); + + return response()->json(['data' => ['operation_id' => $operation->id, 'status' => 'pending']], 202); + } +} diff --git a/core/app/Http/Controllers/Admin/FailedJobController.php b/core/app/Http/Controllers/Admin/FailedJobController.php new file mode 100644 index 0000000..46d0287 --- /dev/null +++ b/core/app/Http/Controllers/Admin/FailedJobController.php @@ -0,0 +1,45 @@ +orderBy('id')->cursorPaginate(50); + $jobs->through(fn ($job): array => [ + 'id' => $job->id, 'uuid' => $job->uuid, 'connection' => $job->connection, 'queue' => $job->queue, + 'job' => data_get(json_decode($job->payload, true), 'displayName', 'unknown'), + 'exception' => mb_substr(strtok($job->exception, "\n") ?: 'Job failed', 0, 500), 'failed_at' => $job->failed_at, + ]); + + return response()->json($jobs); + } + + public function retry(Request $request, string $job): JsonResponse + { + $row = DB::table('failed_jobs')->where('uuid', $job)->orWhere('id', ctype_digit($job) ? (int) $job : -1)->first(); + abort_if($row === null, 404, 'Failed job not found.'); + Artisan::call('queue:retry', ['id' => [$row->uuid]]); + AuditLog::record($request->user(), 'failed_job.retry_requested', null, ['uuid' => $row->uuid, 'queue' => $row->queue], $request->ip()); + + return response()->json(['data' => ['uuid' => $row->uuid, 'status' => 'queued']], 202); + } + + public function destroy(Request $request, string $job): JsonResponse + { + $row = DB::table('failed_jobs')->where('uuid', $job)->orWhere('id', ctype_digit($job) ? (int) $job : -1)->first(); + abort_if($row === null, 404, 'Failed job not found.'); + DB::table('failed_jobs')->where('uuid', $row->uuid)->delete(); + AuditLog::record($request->user(), 'failed_job.deleted', null, ['uuid' => $row->uuid, 'queue' => $row->queue], $request->ip()); + + return response()->json(null, 204); + } +} diff --git a/core/app/Http/Controllers/Admin/ReconciliationController.php b/core/app/Http/Controllers/Admin/ReconciliationController.php new file mode 100644 index 0000000..662f159 --- /dev/null +++ b/core/app/Http/Controllers/Admin/ReconciliationController.php @@ -0,0 +1,38 @@ +where('type', $type)->whereIn('status', ['pending', 'running'])->first(); + if ($operation === null) { + $input = $scope === 'usage' ? ['from' => now()->utc()->subHour()->startOfHour()->toIso8601String(), 'to' => now()->utc()->startOfHour()->toIso8601String()] : []; + $operation = Operation::query()->create(['actor_id' => $request->user()->id, 'type' => $type, 'status' => 'pending', 'input' => $input]); + AuditLog::record($request->user(), "{$scope}.global_reconcile_requested", $operation, [], $request->ip()); + match ($scope) { + 'dns' => ReconcileAllDnsZones::dispatch($operation->id)->afterCommit(), + 'edges' => ReconcileAllEdgeDomains::dispatch($operation->id)->afterCommit(), + 'tls' => ReconcileAllTls::dispatch($operation->id)->afterCommit(), + 'purges' => ReconcileAllPurges::dispatch($operation->id)->afterCommit(), + 'usage' => BuildUsageRollups::dispatch($input['from'], $input['to'], null, $operation->id)->afterCommit(), + }; + } + + return response()->json(['data' => ['operation_id' => $operation->id, 'status' => $operation->status]], 202); + } +} diff --git a/core/app/Http/Controllers/Admin/SystemOperationsController.php b/core/app/Http/Controllers/Admin/SystemOperationsController.php new file mode 100644 index 0000000..a4b91fc --- /dev/null +++ b/core/app/Http/Controllers/Admin/SystemOperationsController.php @@ -0,0 +1,24 @@ +components(); + + return response()->json(['data' => ['status' => $health->overall($components), 'checked_at' => now()->toIso8601String()]]); + } + + public function components(SystemHealth $health): JsonResponse + { + $components = $health->components(); + + return response()->json(['data' => ['status' => $health->overall($components), 'components' => $components, 'queues' => $health->queues()]]); + } +} diff --git a/core/app/Http/Controllers/MetricsController.php b/core/app/Http/Controllers/MetricsController.php new file mode 100644 index 0000000..0ccb8c5 --- /dev/null +++ b/core/app/Http/Controllers/MetricsController.php @@ -0,0 +1,39 @@ +bearerToken()), 404); + $lines = ['# HELP cdnfoundry_component_health Component health (healthy=1).', '# TYPE cdnfoundry_component_health gauge']; + foreach ($health->components() as $name => $component) { + $lines[] = sprintf('cdnfoundry_component_health{component="%s",status="%s"} %d', $name, $component['status'], $component['status'] === 'healthy' ? 1 : 0); + } + foreach ($health->queues() as $queue => $state) { + $lines[] = sprintf('cdnfoundry_queue_depth{queue="%s"} %d', $queue, $state['depth'] ?? 0); + $lines[] = sprintf('cdnfoundry_queue_oldest_job_age_seconds{queue="%s"} %d', $queue, $state['oldest_job_age_seconds'] ?? 0); + } + $lines[] = 'cdnfoundry_operations_failed '.Operation::query()->where('status', 'failed')->count(); + $lines[] = 'cdnfoundry_dns_deployments_drifted '.DnsDeployment::query()->whereIn('status', ['pending', 'failed'])->count(); + $lines[] = 'cdnfoundry_edges_stale '.Edge::query()->where('enabled', true)->where(fn ($query) => $query->whereNull('last_heartbeat_at')->orWhere('last_heartbeat_at', '<', now()->subSeconds(app(PlatformSettings::class)->integer('edge_runtime', 'heartbeat_fresh_seconds'))))->count(); + $lines[] = 'cdnfoundry_tls_certificates_expiring '.TlsCertificate::query()->where('status', 'active')->where('expires_at', '<=', now()->addDays((int) config('services.acme.expiry_alert_days')))->count(); + + return response(implode("\n", $lines)."\n", 200, ['Content-Type' => 'text/plain; version=0.0.4; charset=utf-8', 'Cache-Control' => 'no-store']); + } +} diff --git a/core/app/Http/Controllers/OperationController.php b/core/app/Http/Controllers/OperationController.php index dc9f490..e95ae3f 100644 --- a/core/app/Http/Controllers/OperationController.php +++ b/core/app/Http/Controllers/OperationController.php @@ -3,11 +3,17 @@ namespace App\Http\Controllers; use App\Jobs\ApplyPlatformDnsSettings; +use App\Jobs\BuildUsageRollups; +use App\Jobs\CreateControlBackup; +use App\Jobs\DeleteControlBackup; use App\Jobs\DispatchOriginTest; use App\Jobs\ImportDnsZone; +use App\Jobs\PreflightBackupRestore; use App\Jobs\ProvisionEdgePoolCells; use App\Jobs\ReconcileAllDnsZones; use App\Jobs\ReconcileAllEdgeDomains; +use App\Jobs\ReconcileAllPurges; +use App\Jobs\ReconcileAllTls; use App\Jobs\ReconcileDnsZone; use App\Jobs\ReconcileEdgeDomain; use App\Jobs\TestDnsCluster; @@ -37,7 +43,7 @@ public function index(): AnonymousResourceCollection public function retry(Request $request, Operation $operation): JsonResponse { abort_unless($operation->status === 'failed', 409, 'Only failed operations can be retried.'); - abort_unless(in_array($operation->type, ['platform_dns_identity.update', 'system_settings.update', 'domain.nameservers_verify', 'dns.zone_reconcile', 'dns.zone_import', 'dns.cluster_test', 'dns.global_reconcile', 'edge.global_reconcile', 'edge.pool_provision', 'edge.domain_reconcile', 'edge.origin_test'], true), 422, 'Unsupported operation type.'); + abort_unless(in_array($operation->type, ['platform_dns_identity.update', 'system_settings.update', 'domain.nameservers_verify', 'dns.zone_reconcile', 'dns.zone_import', 'dns.cluster_test', 'dns.global_reconcile', 'edge.global_reconcile', 'edges.global_reconcile', 'tls.global_reconcile', 'purges.global_reconcile', 'usage.global_reconcile', 'backup.create', 'backup.restore', 'backup.delete', 'edge.pool_provision', 'edge.domain_reconcile', 'edge.origin_test'], true), 422, 'Unsupported operation type.'); $operation->update(['status' => 'pending', 'error' => null, 'finished_at' => null]); AuditLog::record($request->user(), 'operation.retry_requested', $operation, [], $request->ip()); match ($operation->type) { @@ -49,6 +55,13 @@ public function retry(Request $request, Operation $operation): JsonResponse 'dns.cluster_test' => TestDnsCluster::dispatch($operation->getKey()), 'dns.global_reconcile' => ReconcileAllDnsZones::dispatch($operation->getKey()), 'edge.global_reconcile' => ReconcileAllEdgeDomains::dispatch($operation->getKey()), + 'edges.global_reconcile' => ReconcileAllEdgeDomains::dispatch($operation->getKey()), + 'tls.global_reconcile' => ReconcileAllTls::dispatch($operation->getKey()), + 'purges.global_reconcile' => ReconcileAllPurges::dispatch($operation->getKey()), + 'usage.global_reconcile' => BuildUsageRollups::dispatch($operation->input['from'], $operation->input['to'], null, $operation->getKey()), + 'backup.create' => CreateControlBackup::dispatch($operation->input['backup_id'], $operation->getKey()), + 'backup.restore' => PreflightBackupRestore::dispatch($operation->input['backup_id'], $operation->getKey()), + 'backup.delete' => DeleteControlBackup::dispatch($operation->input['backup_id'], $operation->getKey()), 'edge.pool_provision' => ProvisionEdgePoolCells::dispatch((int) $operation->input['pool_id'], $operation->id), 'edge.domain_reconcile' => ReconcileEdgeDomain::dispatch((int) $operation->input['domain_id']), 'edge.origin_test' => DispatchOriginTest::dispatch($operation->getKey()), diff --git a/core/app/Jobs/CreateControlBackup.php b/core/app/Jobs/CreateControlBackup.php new file mode 100644 index 0000000..a18c92f --- /dev/null +++ b/core/app/Jobs/CreateControlBackup.php @@ -0,0 +1,51 @@ +onQueue('bulk_maintenance'); + } + + public function uniqueId(): string + { + return $this->backupId; + } + + public function handle(ResticBackupRepository $repository): void + { + $backup = Backup::query()->findOrFail($this->backupId); + $operation = Operation::query()->findOrFail($this->operationId); + if ($backup->status === 'succeeded') { + return; + } + $backup->update(['status' => 'running', 'last_error' => null]); + $operation->update(['status' => 'running', 'started_at' => $operation->started_at ?? now(), 'attempts' => $operation->attempts + 1]); + $result = $repository->create(); + $backup->update([...$result, 'status' => 'succeeded', 'verified_at' => now()]); + $operation->update(['status' => 'succeeded', 'result' => ['backup_id' => $backup->id, ...$result], 'finished_at' => now()]); + } + + public function failed(Throwable $exception): void + { + $error = mb_substr($exception->getMessage(), 0, 4000); + Backup::query()->whereKey($this->backupId)->update(['status' => 'failed', 'last_error' => $error]); + Operation::query()->whereKey($this->operationId)->update(['status' => 'failed', 'error' => $error, 'finished_at' => now()]); + } +} diff --git a/core/app/Jobs/DeleteControlBackup.php b/core/app/Jobs/DeleteControlBackup.php new file mode 100644 index 0000000..30971ef --- /dev/null +++ b/core/app/Jobs/DeleteControlBackup.php @@ -0,0 +1,40 @@ +onQueue('bulk_maintenance'); + } + + public function handle(ResticBackupRepository $repository): void + { + $backup = Backup::query()->findOrFail($this->backupId); + $operation = Operation::query()->findOrFail($this->operationId); + $operation->update(['status' => 'running', 'started_at' => now(), 'attempts' => $operation->attempts + 1]); + if ($backup->snapshot_id) { + $repository->forget($backup->snapshot_id); + } + $backup->delete(); + $operation->update(['status' => 'succeeded', 'result' => ['backup_id' => $this->backupId, 'deleted' => true], 'finished_at' => now()]); + } + + public function failed(Throwable $exception): void + { + Backup::query()->whereKey($this->backupId)->update(['status' => 'failed', 'last_error' => mb_substr($exception->getMessage(), 0, 4000)]); + Operation::query()->whereKey($this->operationId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'finished_at' => now()]); + } +} diff --git a/core/app/Jobs/PreflightBackupRestore.php b/core/app/Jobs/PreflightBackupRestore.php new file mode 100644 index 0000000..ec37c6b --- /dev/null +++ b/core/app/Jobs/PreflightBackupRestore.php @@ -0,0 +1,36 @@ +onQueue('bulk_maintenance'); + } + + public function handle(ResticBackupRepository $repository): void + { + $backup = Backup::query()->whereKey($this->backupId)->where('status', 'succeeded')->firstOrFail(); + $operation = Operation::query()->findOrFail($this->operationId); + $operation->update(['status' => 'running', 'started_at' => now(), 'attempts' => $operation->attempts + 1]); + $repository->snapshotExists($backup->snapshot_id); + $operation->update(['status' => 'running', 'result' => ['backup_id' => $backup->id, 'snapshot_id' => $backup->snapshot_id, 'preflight' => 'passed', 'maintenance_command' => "php artisan backups:restore {$operation->id}"]]); + } + + public function failed(Throwable $exception): void + { + Operation::query()->whereKey($this->operationId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'finished_at' => now()]); + } +} diff --git a/core/app/Jobs/ReconcileAllPurges.php b/core/app/Jobs/ReconcileAllPurges.php new file mode 100644 index 0000000..fc78d43 --- /dev/null +++ b/core/app/Jobs/ReconcileAllPurges.php @@ -0,0 +1,61 @@ +onQueue('bulk_maintenance'); + } + + public function uniqueId(): string + { + return $this->operationId; + } + + public function handle(): void + { + $operation = Operation::query()->findOrFail($this->operationId); + if (! in_array($operation->status, ['pending', 'running'], true)) { + return; + } + $cursor = (string) ($operation->result['cursor'] ?? ''); + $count = (int) ($operation->result['purges_requeued'] ?? 0); + $purges = CachePurge::query()->whereIn('status', ['pending', 'running', 'failed'])->when($cursor !== '', fn ($query) => $query->where('id', '>', $cursor))->orderBy('id')->limit(250)->get(); + $operation->update(['status' => 'running', 'started_at' => $operation->started_at ?? now(), 'attempts' => $operation->attempts + 1]); + foreach ($purges as $purge) { + DB::transaction(function () use ($purge): void { + EdgeTask::query()->where('cache_purge_id', $purge->id)->where('status', 'failed')->update(['status' => 'pending', 'attempts' => 0, 'last_error' => null, 'available_at' => now()]); + $purge->update(['status' => $purge->tasks()->where('status', 'failed')->exists() ? 'failed' : 'running']); + }); + $count++; + } + $cursor = (string) ($purges->last()?->id ?? $cursor); + $more = $purges->count() === 250 && CachePurge::query()->whereIn('status', ['pending', 'running', 'failed'])->where('id', '>', $cursor)->exists(); + $operation->update(['status' => $more ? 'running' : 'succeeded', 'result' => ['cursor' => $cursor, 'purges_requeued' => $count], 'finished_at' => $more ? null : now()]); + if ($more) { + self::dispatch($operation->id)->delay(now()->addSecond()); + } + } + + public function failed(Throwable $exception): void + { + Operation::query()->whereKey($this->operationId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'finished_at' => now()]); + } +} diff --git a/core/app/Jobs/ReconcileAllTls.php b/core/app/Jobs/ReconcileAllTls.php new file mode 100644 index 0000000..93189f2 --- /dev/null +++ b/core/app/Jobs/ReconcileAllTls.php @@ -0,0 +1,57 @@ +onQueue('bulk_maintenance'); + } + + public function uniqueId(): string + { + return $this->operationId; + } + + public function handle(): void + { + $operation = Operation::query()->findOrFail($this->operationId); + if (! in_array($operation->status, ['pending', 'running'], true)) { + return; + } + $cursor = (int) ($operation->result['cursor'] ?? 0); + $count = (int) ($operation->result['domains_dispatched'] ?? 0); + $ids = Domain::query()->where('lifecycle_state', DomainLifecycleState::Active->value)->where('tls_mode', 'managed')->where('id', '>', $cursor)->orderBy('id')->limit(250)->pluck('id'); + $operation->update(['status' => 'running', 'started_at' => $operation->started_at ?? now(), 'attempts' => $operation->attempts + 1]); + foreach ($ids as $id) { + EnsureManagedCertificates::dispatch((int) $id); + } + $count += $ids->count(); + $cursor = (int) ($ids->last() ?? $cursor); + $more = $ids->count() === 250 && Domain::query()->where('lifecycle_state', DomainLifecycleState::Active->value)->where('tls_mode', 'managed')->where('id', '>', $cursor)->exists(); + $operation->update(['status' => $more ? 'running' : 'succeeded', 'result' => ['cursor' => $cursor, 'domains_dispatched' => $count], 'finished_at' => $more ? null : now()]); + if ($more) { + self::dispatch($operation->id)->delay(now()->addSecond()); + } + } + + public function failed(Throwable $exception): void + { + Operation::query()->whereKey($this->operationId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'finished_at' => now()]); + } +} diff --git a/core/app/Models/Backup.php b/core/app/Models/Backup.php new file mode 100644 index 0000000..ace19c9 --- /dev/null +++ b/core/app/Models/Backup.php @@ -0,0 +1,18 @@ + 'immutable_datetime']; + } +} diff --git a/core/app/Support/ResticBackupRepository.php b/core/app/Support/ResticBackupRepository.php new file mode 100644 index 0000000..357add4 --- /dev/null +++ b/core/app/Support/ResticBackupRepository.php @@ -0,0 +1,75 @@ +run(['/usr/local/bin/cdnf-backup-create']); + $events = collect(preg_split('/\R/', trim($output)))->filter()->map(fn (string $line) => json_decode($line, true))->filter(fn ($row) => is_array($row)); + $summary = $events->last(fn (array $row) => isset($row['snapshot_id'])); + if (! is_array($summary)) { + throw new RuntimeException('Restic did not return a snapshot identifier.'); + } + + return ['snapshot_id' => $summary['snapshot_id'], 'size_bytes' => (int) ($summary['data_added'] ?? $summary['total_bytes_processed'] ?? 0), 'manifest_sha256' => hash('sha256', $output)]; + } + + public function snapshotExists(string $snapshotId): bool + { + $this->assertSnapshot($snapshotId); + $this->run(['restic', 'snapshots', '--json', $snapshotId]); + + return true; + } + + public function forget(string $snapshotId): void + { + $this->assertSnapshot($snapshotId); + $this->run(['restic', 'forget', $snapshotId]); + } + + public function restore(string $snapshotId): void + { + $this->assertSnapshot($snapshotId); + $this->run(['/usr/local/bin/cdnf-backup-restore', $snapshotId], 7200); + } + + private function run(array $command, int $timeout = 3600): string + { + if (! $this->configured()) { + throw new RuntimeException('Encrypted off-host backup repository is not configured.'); + } + $process = new Process($command, null, [ + 'RESTIC_REPOSITORY' => config('services.backups.repository'), + 'RESTIC_PASSWORD_FILE' => config('services.backups.password_file'), + 'AWS_ACCESS_KEY_ID' => config('services.backups.access_key'), + 'AWS_SECRET_ACCESS_KEY' => config('services.backups.secret_key'), + 'AWS_DEFAULT_REGION' => config('services.backups.region'), + 'PGHOST' => config('database.connections.pgsql.host'), + 'PGPORT' => (string) config('database.connections.pgsql.port'), + 'PGDATABASE' => config('database.connections.pgsql.database'), + 'PGUSER' => config('database.connections.pgsql.username'), + 'PGPASSWORD' => config('database.connections.pgsql.password'), + ]); + $process->setTimeout($timeout)->mustRun(); + + return $process->getOutput(); + } + + private function assertSnapshot(string $snapshotId): void + { + if (! preg_match('/^[a-f0-9]{8,128}$/', $snapshotId)) { + throw new RuntimeException('Invalid backup snapshot identifier.'); + } + } +} diff --git a/core/app/Support/SystemHealth.php b/core/app/Support/SystemHealth.php new file mode 100644 index 0000000..f826ac1 --- /dev/null +++ b/core/app/Support/SystemHealth.php @@ -0,0 +1,228 @@ +probe(fn () => DB::select('select 1')); + $components['queue_backend'] = $this->probe(fn () => Redis::connection()->command('ping')); + $components['queue_workers'] = $this->horizon(); + $heartbeat = Cache::get('operations:scheduler_heartbeat'); + $schedulerStale = $heartbeat === null || now()->diffInSeconds($heartbeat) > app(PlatformSettings::class)->integer('operations', 'scheduler_stale_seconds'); + $components['scheduler'] = $this->state($schedulerStale ? 'degraded' : 'healthy', ['last_heartbeat_at' => $heartbeat]); + $components['clickhouse'] = $this->probe(fn () => Http::connectTimeout(1)->timeout(2)->get(config('services.clickhouse.url').'/ping')->throw()); + $components['vector'] = $this->probe(fn () => Http::connectTimeout(1)->timeout(2)->get(config('services.vector.metrics_url'))->throw()); + $components['host_clock'] = $this->clock(); + $components['mmdb'] = $this->mmdb(); + + $heartbeatSeconds = app(PlatformSettings::class)->integer('edge_runtime', 'heartbeat_fresh_seconds'); + $enabledEdges = Edge::query()->where('enabled', true)->count(); + $staleEdges = Edge::query()->where('enabled', true)->where(fn ($query) => $query->whereNull('last_heartbeat_at')->orWhere('last_heartbeat_at', '<', now()->subSeconds($heartbeatSeconds)))->count(); + $components['edges'] = $this->state($enabledEdges === 0 ? 'degraded' : ($staleEdges > 0 ? 'degraded' : 'healthy'), ['enabled' => $enabledEdges, 'stale' => $staleEdges]); + $listenerFailures = Edge::query()->where('enabled', true)->where('drained', false) + ->where(fn ($query) => $query->whereNull('capacity->listener_ready')->orWhere('capacity->listener_ready', '!=', true))->count(); + $components['edge_listeners'] = $this->state($listenerFailures > 0 ? 'degraded' : 'healthy', ['not_ready' => $listenerFailures]); + + $enabledCells = EdgeCell::query()->whereHas('edge', fn ($query) => $query->where('enabled', true))->count(); + $unhealthyCells = EdgeCell::query()->whereHas('edge', fn ($query) => $query->where('enabled', true)) + ->whereNotIn('status', ['ready', 'drained'])->count(); + $components['edge_cells'] = $this->state($enabledCells === 0 || $unhealthyCells > 0 ? 'degraded' : 'healthy', ['assigned' => $enabledCells, 'unhealthy' => $unhealthyCells]); + + $enabledPools = EdgePool::query()->where('enabled', true)->where('withdrawn', false)->count(); + $unavailablePools = EdgePool::query()->where('enabled', true)->where('withdrawn', false) + ->whereDoesntHave('cells', fn ($query) => $query->where('drained', false)->where('status', 'ready') + ->whereHas('edge', fn ($edge) => $edge->readyForTraffic()))->count(); + $components['service_pools'] = $this->state($enabledPools === 0 || $unavailablePools > 0 ? 'degraded' : 'healthy', ['enabled' => $enabledPools, 'unavailable' => $unavailablePools]); + + $latestArtifacts = DB::table('edge_artifacts')->selectRaw('edge_id, max(sequence) as latest_sequence')->groupBy('edge_id'); + $configurationDrift = Edge::query()->where('edges.enabled', true)->joinSub($latestArtifacts, 'latest_edge_artifacts', fn ($join) => $join->on('edges.id', '=', 'latest_edge_artifacts.edge_id')) + ->whereColumn('edges.active_sequence', '<', 'latest_edge_artifacts.latest_sequence')->count(); + $deploymentRejections = Edge::query()->where('enabled', true)->whereNotNull('capacity->last_rejection')->count(); + $components['edge_configuration'] = $this->state(($configurationDrift + $deploymentRejections) > 0 ? 'degraded' : 'healthy', ['stale_edges' => $configurationDrift, 'rejected_candidates' => $deploymentRejections]); + + $failedPlacements = DomainEdgePlacement::query()->where('state', 'failed')->count(); + $placementDrift = DomainEdgePlacement::query()->where('state', 'active')->join('domains', 'domains.id', '=', 'domain_edge_placements.domain_id') + ->where(fn ($query) => $query->whereNull('domains.active_edge_revision')->orWhereColumn('domains.active_edge_revision', '<', 'domain_edge_placements.desired_revision'))->count(); + $components['edge_placements'] = $this->state(($failedPlacements + $placementDrift) > 0 ? 'degraded' : 'healthy', ['failed' => $failedPlacements, 'drifted' => $placementDrift]); + $components['edge_capacity'] = $this->edgeCapacity(); + + $activeEmergencyModes = EmergencyMode::query()->where('active', true)->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))->count(); + $withdrawnPools = EdgePool::query()->where('enabled', true)->where('withdrawn', true)->count(); + $components['emergency_modes'] = $this->state(($activeEmergencyModes + $withdrawnPools) > 0 ? 'degraded' : 'healthy', ['active' => $activeEmergencyModes, 'withdrawn_pools' => $withdrawnPools]); + + $enabledClusters = DnsCluster::query()->where('enabled', true)->count(); + $badClusters = DnsCluster::query()->where('enabled', true)->where(fn ($query) => $query->where('last_health_status', '!=', 'healthy')->orWhereNull('last_health_at'))->count(); + $dnsDrift = DnsDeployment::query()->whereIn('status', ['failed', 'pending'])->count(); + $components['authoritative_dns'] = $this->state($enabledClusters === 0 ? 'degraded' : (($badClusters + $dnsDrift) > 0 ? 'degraded' : 'healthy'), ['enabled_clusters' => $enabledClusters, 'unhealthy_clusters' => $badClusters, 'drifted_deployments' => $dnsDrift]); + $components['dns_deployments'] = $this->state($dnsDrift > 0 ? 'degraded' : 'healthy', ['drifted' => $dnsDrift]); + + $expiring = TlsCertificate::query()->where('status', 'active')->where('expires_at', '<=', now()->addDays((int) config('services.acme.expiry_alert_days')))->count(); + $failedOrders = TlsOrder::query()->where('status', 'failed')->count(); + $components['tls'] = $this->state(($expiring + $failedOrders) > 0 ? 'degraded' : 'healthy', ['expiring_certificates' => $expiring, 'failed_orders' => $failedOrders]); + + $failedPurges = CachePurge::query()->where('status', 'failed')->count(); + $failedTasks = EdgeTask::query()->where('status', 'failed')->count(); + $components['runtime_tasks'] = $this->state(($failedPurges + $failedTasks) > 0 ? 'degraded' : 'healthy', ['failed_purges' => $failedPurges, 'failed_edge_tasks' => $failedTasks]); + $pendingPurges = CachePurge::query()->whereIn('status', ['pending', 'running'])->count(); + $components['purges'] = $this->state($failedPurges > 0 ? 'degraded' : 'healthy', ['failed' => $failedPurges, 'pending' => $pendingPurges]); + + $usageLag = UsageRollup::query()->where('status', 'finalized')->max('interval_end'); + $components['usage'] = $this->state($usageLag === null || now()->diffInHours($usageLag) > 3 ? 'degraded' : 'healthy', ['last_finalized_interval' => $usageLag]); + $components['operations'] = $this->state(Operation::query()->where('status', 'failed')->exists() ? 'degraded' : 'healthy', ['failed' => Operation::query()->where('status', 'failed')->count()]); + $lastBackup = Backup::query()->where('status', 'succeeded')->whereNotNull('verified_at')->max('verified_at'); + $backupStale = $lastBackup === null || now()->diffInHours($lastBackup) > app(PlatformSettings::class)->integer('operations', 'backup_stale_hours'); + $components['backups'] = $this->state($backupStale ? 'degraded' : 'healthy', ['last_verified_at' => $lastBackup]); + + return $components; + } + + public function queues(): array + { + return collect(self::QUEUES)->mapWithKeys(function (string $queue): array { + try { + $depth = (int) Redis::connection()->llen("queues:{$queue}"); + $payload = $depth > 0 ? json_decode((string) Redis::connection()->lindex("queues:{$queue}", 0), true) : null; + $pushedAt = is_array($payload) ? ($payload['pushedAt'] ?? $payload['pushed_at'] ?? null) : null; + $oldestAge = is_numeric($pushedAt) ? max(0, (int) floor(microtime(true) - $pushedAt)) : null; + + return [$queue => ['status' => $depth > 1000 || ($oldestAge !== null && $oldestAge > 900) ? 'degraded' : 'healthy', 'depth' => $depth, 'oldest_job_age_seconds' => $oldestAge]]; + } catch (Throwable) { + return [$queue => ['status' => 'unavailable', 'depth' => null, 'oldest_job_age_seconds' => null]]; + } + })->all(); + } + + public function overall(array $components): string + { + if (collect(['control_database', 'queue_backend'])->contains(fn (string $name) => ($components[$name]['status'] ?? 'unavailable') === 'unavailable')) { + return 'unavailable'; + } + + return collect($components)->contains(fn (array $component) => $component['status'] !== 'healthy') ? 'degraded' : 'healthy'; + } + + private function probe(callable $probe): array + { + $started = hrtime(true); + try { + $probe(); + + return $this->state('healthy', ['latency_ms' => round((hrtime(true) - $started) / 1_000_000, 2)]); + } catch (Throwable $exception) { + return $this->state('unavailable', ['latency_ms' => round((hrtime(true) - $started) / 1_000_000, 2), 'error_code' => class_basename($exception)]); + } + } + + private function clock(): array + { + try { + $response = Http::connectTimeout(1)->timeout(2)->get(config('services.prometheus.url').'/api/v1/query', ['query' => 'node_timex_offset_seconds'])->throw()->json(); + $rows = data_get($response, 'data.result', []); + if (! is_array($rows) || $rows === []) { + return $this->state('degraded', ['error_code' => 'clock_metric_missing']); + } + $offset = collect($rows)->map(fn (array $row): float => abs((float) data_get($row, 'value.1', 0)))->max(); + $limit = app(PlatformSettings::class)->integer('operations', 'clock_drift_warning_seconds'); + + return $this->state($offset > $limit ? 'degraded' : 'healthy', ['maximum_offset_seconds' => $offset, 'warning_seconds' => $limit, 'sources' => count($rows)]); + } catch (Throwable $exception) { + return $this->state('unavailable', ['error_code' => class_basename($exception)]); + } + } + + private function horizon(): array + { + try { + $masters = app(MasterSupervisorRepository::class)->all(); + $running = collect($masters)->where('status', 'running')->count(); + + return $this->state($running > 0 && $running === count($masters) ? 'healthy' : 'degraded', [ + 'running_masters' => $running, + 'known_masters' => count($masters), + ]); + } catch (Throwable $exception) { + return $this->state('unavailable', ['error_code' => class_basename($exception)]); + } + } + + private function mmdb(): array + { + $path = (string) config('services.geoip.database'); + $maximumAgeHours = (int) config('services.geoip.stale_hours', 48); + clearstatcache(true, $path); + if (! is_file($path) || ! is_readable($path) || filesize($path) === 0) { + return $this->state('unavailable', ['error_code' => 'mmdb_missing']); + } + $modified = filemtime($path); + if ($modified === false) { + return $this->state('unavailable', ['error_code' => 'mmdb_stat_failed']); + } + $ageHours = max(0, (now()->timestamp - $modified) / 3600); + + return $this->state($ageHours > $maximumAgeHours ? 'degraded' : 'healthy', [ + 'updated_at' => date(DATE_ATOM, $modified), + 'age_hours' => round($ageHours, 2), + 'stale_after_hours' => $maximumAgeHours, + ]); + } + + private function edgeCapacity(): array + { + $cells = EdgeCell::query()->whereHas('edge', fn ($query) => $query->where('enabled', true))->limit(1001)->get(['capacity']); + $truncated = $cells->count() > 1000; + $pressured = $cells->take(1000)->filter(function (EdgeCell $cell): bool { + $capacity = $cell->capacity ?? []; + foreach ([ + ['memory_usage', 'memory_limit'], + ['cache_usage', 'cache_limit'], + ['temporary_storage_usage', 'temporary_storage_limit'], + ['active_connections', 'connection_limit'], + ] as [$usedKey, $limitKey]) { + $used = data_get($capacity, $usedKey); + $limit = data_get($capacity, $limitKey); + if (is_numeric($used) && is_numeric($limit) && (float) $limit > 0 && ((float) $used / (float) $limit) >= 0.8) { + return true; + } + } + + return false; + })->count(); + + return $this->state($truncated || $pressured > 0 ? 'degraded' : 'healthy', [ + 'pressured_cells' => $pressured, + 'scanned_cells' => min(1000, $cells->count()), + 'scan_truncated' => $truncated, + ]); + } + + private function state(string $status, array $details): array + { + return ['status' => $status, 'checked_at' => now()->toIso8601String(), 'details' => $details]; + } +} diff --git a/core/config/platform.php b/core/config/platform.php index 2b16843..9b9beae 100644 --- a/core/config/platform.php +++ b/core/config/platform.php @@ -2,6 +2,16 @@ return [ 'groups' => [ + 'operations' => [ + 'label' => 'Operations and recovery', + 'description' => 'Bounded retention and freshness thresholds used by health checks, alerts, and recovery policy.', + 'fields' => [ + 'audit_retention_days' => ['type' => 'integer', 'label' => 'Audit retention (days)', 'default' => 365, 'description' => 'Delete audit events older than this period in bounded daily batches.', 'rules' => ['required', 'integer', 'between:30,3650']], + 'scheduler_stale_seconds' => ['type' => 'integer', 'label' => 'Scheduler stale threshold (seconds)', 'default' => 180, 'description' => 'Mark scheduler health degraded when its durable heartbeat is older than this threshold.', 'rules' => ['required', 'integer', 'between:60,3600']], + 'clock_drift_warning_seconds' => ['type' => 'integer', 'label' => 'Clock drift warning (seconds)', 'default' => 5, 'description' => 'Maximum accepted host clock offset for external monitoring and qualification.', 'rules' => ['required', 'integer', 'between:1,300']], + 'backup_stale_hours' => ['type' => 'integer', 'label' => 'Backup stale threshold (hours)', 'default' => 26, 'description' => 'Maximum age of the most recent verified encrypted off-host control database backup.', 'rules' => ['required', 'integer', 'between:1,168']], + ], + ], 'telemetry' => [ 'label' => 'Telemetry retention and privacy', 'description' => 'Bounded raw telemetry, aggregate retention, finalization delay, and client-address masking.', diff --git a/core/config/services.php b/core/config/services.php index 214fb36..1665b42 100644 --- a/core/config/services.php +++ b/core/config/services.php @@ -13,7 +13,18 @@ 'max_rows_to_read' => 10000000, 'max_result_rows' => 10001, ], - 'geoip' => ['database' => env('GEOIP_DATABASE', '/mmdb/GeoLite2-City.mmdb')], + 'geoip' => [ + 'database' => env('GEOIP_DATABASE', '/mmdb/GeoLite2-City.mmdb'), + 'stale_hours' => (int) env('MMDB_STALE_HOURS', 48), + ], + 'vector' => ['metrics_url' => env('VECTOR_METRICS_URL', 'http://vector:9598/metrics')], + 'metrics' => ['token' => env('METRICS_TOKEN', ''), 'token_file' => env('METRICS_TOKEN_FILE')], + 'backups' => [ + 'repository' => env('RESTIC_REPOSITORY', ''), 'password_file' => env('RESTIC_PASSWORD_FILE', ''), + 'access_key' => env('BACKUP_ACCESS_KEY_ID', ''), 'secret_key' => env('BACKUP_SECRET_ACCESS_KEY', ''), + 'region' => env('BACKUP_DEFAULT_REGION', 'us-east-1'), + ], + 'prometheus' => ['url' => env('PROMETHEUS_URL', 'http://prometheus:9090')], 'acme' => [ 'enabled' => filter_var(env('ACME_ENABLED', false), FILTER_VALIDATE_BOOL), 'verify_tls' => filter_var(env('ACME_VERIFY_TLS', true), FILTER_VALIDATE_BOOL), diff --git a/core/database/migrations/2026_07_20_010000_add_operations_settings.php b/core/database/migrations/2026_07_20_010000_add_operations_settings.php new file mode 100644 index 0000000..e300eef --- /dev/null +++ b/core/database/migrations/2026_07_20_010000_add_operations_settings.php @@ -0,0 +1,19 @@ +mapWithKeys(fn (array $field, string $key): array => [$key => $field['default']])->all(); + DB::table('system_settings')->insertOrIgnore(['group' => 'operations', 'values' => json_encode($values, JSON_THROW_ON_ERROR), 'revision' => 1, 'created_at' => now(), 'updated_at' => now()]); + } + + public function down(): void + { + DB::table('system_settings')->where('group', 'operations')->delete(); + } +}; diff --git a/core/database/migrations/2026_07_20_020000_create_backups_table.php b/core/database/migrations/2026_07_20_020000_create_backups_table.php new file mode 100644 index 0000000..634166b --- /dev/null +++ b/core/database/migrations/2026_07_20_020000_create_backups_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('status', 20)->default('pending'); + $table->string('snapshot_id', 128)->nullable()->unique(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->char('manifest_sha256', 64)->nullable(); + $table->text('last_error')->nullable(); + $table->timestampTz('verified_at')->nullable(); + $table->timestampsTz(); + $table->index(['status', 'created_at']); + }); + if (DB::getDriverName() === 'pgsql') { + DB::statement("ALTER TABLE backups ADD CONSTRAINT backups_status_check CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'deleting'))"); + } + } + + public function down(): void + { + Schema::dropIfExists('backups'); + } +}; diff --git a/core/database/migrations/2026_07_20_030000_add_operational_health_indexes.php b/core/database/migrations/2026_07_20_030000_add_operational_health_indexes.php new file mode 100644 index 0000000..91a91cf --- /dev/null +++ b/core/database/migrations/2026_07_20_030000_add_operational_health_indexes.php @@ -0,0 +1,49 @@ + $table->index(['state', 'desired_revision', 'domain_id'], 'domain_edge_placements_health_idx')); + Schema::table('edge_cells', fn (Blueprint $table) => $table->index(['status', 'drained', 'edge_id'], 'edge_cells_health_idx')); + Schema::table('cache_purges', fn (Blueprint $table) => $table->index(['status', 'created_at'], 'cache_purges_health_idx')); + Schema::table('usage_rollups', fn (Blueprint $table) => $table->index(['status', 'interval_end'], 'usage_rollups_health_idx')); + Schema::table('backups', fn (Blueprint $table) => $table->index(['status', 'verified_at'], 'backups_health_idx')); + } + + public function down(): void + { + if (DB::getDriverName() === 'pgsql') { + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS domain_edge_placements_health_idx'); + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS edge_cells_health_idx'); + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS cache_purges_health_idx'); + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS usage_rollups_health_idx'); + DB::statement('DROP INDEX CONCURRENTLY IF EXISTS backups_health_idx'); + + return; + } + + Schema::table('domain_edge_placements', fn (Blueprint $table) => $table->dropIndex('domain_edge_placements_health_idx')); + Schema::table('edge_cells', fn (Blueprint $table) => $table->dropIndex('edge_cells_health_idx')); + Schema::table('cache_purges', fn (Blueprint $table) => $table->dropIndex('cache_purges_health_idx')); + Schema::table('usage_rollups', fn (Blueprint $table) => $table->dropIndex('usage_rollups_health_idx')); + Schema::table('backups', fn (Blueprint $table) => $table->dropIndex('backups_health_idx')); + } +}; diff --git a/core/docker/backup/create.sh b/core/docker/backup/create.sh new file mode 100644 index 0000000..4ce57bf --- /dev/null +++ b/core/docker/backup/create.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +pg_dump --format=custom --no-owner --no-privileges | restic backup --stdin --stdin-filename control.pgdump --tag cdnfoundry-control --json diff --git a/core/docker/backup/restore.sh b/core/docker/backup/restore.sh new file mode 100644 index 0000000..9ee3e7a --- /dev/null +++ b/core/docker/backup/restore.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +snapshot_id="${1:-}" +case "$snapshot_id" in *[!a-f0-9]*|'') exit 64 ;; esac +restic dump "$snapshot_id" control.pgdump | pg_restore --clean --if-exists --no-owner --no-privileges --exit-on-error --dbname="$PGDATABASE" diff --git a/core/resources/views/filament/admin/pages/dashboard.blade.php b/core/resources/views/filament/admin/pages/dashboard.blade.php index da98a75..98d2726 100644 --- a/core/resources/views/filament/admin/pages/dashboard.blade.php +++ b/core/resources/views/filament/admin/pages/dashboard.blade.php @@ -7,6 +7,17 @@