|null $selectedItemIds */ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array { $this->assertActiveContext($tenant, $backupSet); $items = $this->loadItems($backupSet, $selectedItemIds); return $items->map(function (BackupItem $item) use ($tenant) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) ->where('policy_type', $item->policy_type) ->first(); return [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, 'platform' => $item->platform, 'action' => $existing ? 'update' : 'create', 'conflict' => false, 'validation_warning' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ]; })->all(); } /** * Execute restore or dry-run for selected items. * * @param array|null $selectedItemIds */ public function execute( Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, bool $dryRun = true, ?string $actorEmail = null, ?string $actorName = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); $restoreRun = RestoreRun::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'requested_by' => $actorEmail, 'is_dry_run' => $dryRun, 'status' => 'running', 'requested_items' => $selectedItemIds, 'preview' => $preview, 'started_at' => CarbonImmutable::now(), 'metadata' => [], ]); $results = []; $hardFailures = 0; foreach ($items as $item) { $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $item->policy_type, 'policy_id' => $item->policy_identifier, 'backup_item_id' => $item->id, ]; $odataValidation = BackupItem::validateODataType( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ); if (! $odataValidation['matches']) { $results[] = $context + [ 'status' => 'failed', 'reason' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, $item->platform ) ?? 'Snapshot type mismatch', 'code' => 'odata_mismatch', ]; $hardFailures++; continue; } if ($dryRun) { $results[] = $context + ['status' => 'dry_run']; continue; } $this->graphLogger->logRequest('apply_policy', $context); try { $originalPayload = is_array($item->payload) ? $item->payload : []; // sanitize high-level fields according to contract $payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload); $graphOptions = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ]; $settingsApply = null; $itemStatus = 'applied'; if ($item->policy_type === 'settingsCatalogPolicy') { $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( $item->policy_type, $item->policy_identifier, $policyPayload, $graphOptions ); if ($response->successful() && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, context: $context, ); } elseif ($settings !== []) { $settingsApply = [ 'total' => count($settings), 'applied' => 0, 'failed' => count($settings), 'manual_required' => 0, 'issues' => [], ]; } } else { $response = $this->graphClient->applyPolicy( $item->policy_type, $item->policy_identifier, $payload, $graphOptions ); } } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); $results[] = $context + [ 'status' => 'failed', 'reason' => $mapped->getMessage(), 'code' => $mapped->status, 'graph_error_message' => $mapped->getMessage(), 'graph_error_code' => $mapped->status, ]; $hardFailures++; continue; } $this->graphLogger->logResponse('apply_policy', $response, $context); if ($response->failed()) { $results[] = $context + [ 'status' => 'failed', 'reason' => 'Graph apply failed', 'code' => $response->status, 'graph_error_message' => $response->meta['error_message'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, ]; $hardFailures++; continue; } $result = $context + ['status' => $itemStatus]; if ($settingsApply !== null) { $result['settings_apply'] = $settingsApply; } if ($itemStatus !== 'applied') { $result['reason'] = 'Some settings require attention'; } $results[] = $result; $appliedPolicyId = $item->policy_identifier; $policy = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $appliedPolicyId) ->where('policy_type', $item->policy_type) ->first(); if ($policy && $itemStatus === 'applied') { $this->versionService->captureVersion( policy: $policy, payload: $item->payload, createdBy: $actorEmail, metadata: [ 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, ] ); } } $resultStatuses = collect($results)->pluck('status')->all(); $nonApplied = collect($resultStatuses)->filter(fn (string $status) => $status !== 'applied' && $status !== 'dry_run')->count(); $allHardFailed = count($results) > 0 && $hardFailures === count($results); $status = $dryRun ? 'previewed' : (match (true) { $allHardFailed => 'failed', $nonApplied > 0 => 'partial', default => 'completed', }); $restoreRun->update([ 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), 'metadata' => [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, 'total' => count($results), ], ]); $this->auditLogger->log( tenant: $tenant, action: $dryRun ? 'restore.previewed' : 'restore.executed', context: [ 'metadata' => [ 'restore_run_id' => $restoreRun->id, 'backup_set_id' => $backupSet->id, 'status' => $status, 'dry_run' => $dryRun, ], ], actorEmail: $actorEmail, actorName: $actorName, resourceType: 'restore_run', resourceId: (string) $restoreRun->id, status: $status === 'completed' || $status === 'previewed' ? 'success' : 'partial' ); return $restoreRun->refresh(); } /** * @param array|null $selectedItemIds */ private function loadItems(BackupSet $backupSet, ?array $selectedItemIds = null): Collection { $query = $backupSet->items()->getQuery(); if ($selectedItemIds !== null) { $query->whereIn('id', $selectedItemIds); } return $query->orderBy('id')->get(); } /** * Strip read-only/metadata fields before sending payload back to Graph. */ private function sanitizePayload(array $payload): array { $readOnlyKeys = [ '@odata.context', 'id', 'createdDateTime', 'lastModifiedDateTime', 'version', 'supportsScopeTags', 'roleScopeTagIds', ]; $clean = []; foreach ($payload as $key => $value) { // Drop read-only/meta keys except @odata.type which we keep for type hinting if (in_array($key, $readOnlyKeys, true)) { continue; } // Keep @odata.type for Graph type resolution if ($key === '@odata.type') { $clean[$key] = $value; continue; } if (is_array($value)) { $clean[$key] = $this->sanitizePayload($value); continue; } $clean[$key] = $value; } return $clean; } /** * @return array */ private function extractSettingsCatalogSettings(array $payload): array { foreach ($payload as $key => $value) { if (strtolower((string) $key) !== 'settings') { continue; } return is_array($value) ? $value : []; } return []; } private function stripSettingsFromPayload(array $payload): array { foreach (array_keys($payload) as $key) { if (strtolower((string) $key) === 'settings') { unset($payload[$key]); } } return $payload; } private function resolveSettingsCatalogSettingId(array $setting): ?string { foreach ($setting as $key => $value) { if (strtolower((string) $key) !== 'id') { continue; } if (is_string($value) || is_int($value)) { return (string) $value; } return null; } return null; } /** * @param array $settings * @param array $graphOptions * @param array $context * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( string $policyId, array $settings, array $graphOptions, array $context, ): array { $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); $issues = []; $applied = 0; $failed = 0; $manualRequired = 0; foreach ($settings as $setting) { if (! is_array($setting)) { continue; } $settingId = $this->resolveSettingsCatalogSettingId($setting); $path = ($method && $settingId) ? $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId, $settingId) : null; if (! $method || ! $path || ! $settingId) { $manualRequired++; $issues[] = array_filter([ 'setting_id' => $settingId, 'status' => 'manual_required', 'reason' => ! $settingId ? 'Setting id missing (cannot apply automatically).' : 'Settings write contract is not configured (cannot apply automatically).', ], static fn ($value) => $value !== null && $value !== ''); continue; } $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', [$setting])[0] ?? null; if (! is_array($sanitized) || $sanitized === []) { $manualRequired++; $issues[] = [ 'setting_id' => $settingId, 'status' => 'manual_required', 'reason' => 'Setting payload could not be sanitized (empty payload).', ]; continue; } $this->graphLogger->logRequest('apply_setting', $context + [ 'setting_id' => $settingId, 'endpoint' => $path, 'method' => $method, ]); $response = $this->graphClient->request($method, $path, ['json' => $sanitized] + Arr::except($graphOptions, ['platform'])); $this->graphLogger->logResponse('apply_setting', $response, $context + [ 'setting_id' => $settingId, 'endpoint' => $path, 'method' => $method, ]); if ($response->successful()) { $applied++; continue; } if ($response->status === 404) { $manualRequired++; $issues[] = [ 'setting_id' => $settingId, 'status' => 'manual_required', 'reason' => 'Setting not found on target policy (404).', 'graph_error_message' => $response->meta['error_message'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, ]; continue; } $failed++; $issues[] = [ 'setting_id' => $settingId, 'status' => 'failed', 'reason' => 'Graph apply failed', 'graph_error_message' => $response->meta['error_message'] ?? null, 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, ]; } $summary = [ 'total' => count($settings), 'applied' => $applied, 'failed' => $failed, 'manual_required' => $manualRequired, 'issues' => $issues, ]; $status = match (true) { $manualRequired > 0 => 'manual_required', $failed > 0 => 'partial', default => 'applied', }; return [$summary, $status]; } private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); } if ($backupSet->trashed()) { throw new \RuntimeException('Backup set is archived.'); } } }