684 lines
25 KiB
PHP
684 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Jobs;
|
|
|
|
use App\Jobs\Middleware\TrackOperationRun;
|
|
use App\Models\BackupItem;
|
|
use App\Models\BackupSet;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Policy;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Intune\FoundationSnapshotService;
|
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
|
use App\Services\Intune\SnapshotValidator;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\RunFailureSanitizer;
|
|
use Filament\Notifications\Notification;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
class AddPoliciesToBackupSetJob implements ShouldQueue
|
|
{
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public ?OperationRun $operationRun = null;
|
|
|
|
/**
|
|
* @param array<int, int> $policyIds
|
|
* @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options
|
|
*/
|
|
public function __construct(
|
|
public int $tenantId,
|
|
public int $userId,
|
|
public int $backupSetId,
|
|
public array $policyIds,
|
|
public array $options,
|
|
public string $idempotencyKey,
|
|
?OperationRun $operationRun = null
|
|
) {
|
|
$this->operationRun = $operationRun;
|
|
}
|
|
|
|
public function middleware(): array
|
|
{
|
|
return [new TrackOperationRun];
|
|
}
|
|
|
|
public function handle(
|
|
OperationRunService $operationRunService,
|
|
PolicyCaptureOrchestrator $captureOrchestrator,
|
|
FoundationSnapshotService $foundationSnapshots,
|
|
SnapshotValidator $snapshotValidator,
|
|
): void {
|
|
if (! $this->operationRun instanceof OperationRun) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::query()->find($this->tenantId);
|
|
$initiator = User::query()->find($this->userId);
|
|
|
|
$policyIds = $this->normalizePolicyIds($this->policyIds);
|
|
$includeAssignments = (bool) ($this->options['include_assignments'] ?? false);
|
|
$includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false);
|
|
$includeFoundations = (bool) ($this->options['include_foundations'] ?? false);
|
|
|
|
try {
|
|
if (! $tenant instanceof Tenant) {
|
|
$this->failRun(
|
|
operationRunService: $operationRunService,
|
|
tenant: null,
|
|
code: 'tenant.not_found',
|
|
message: 'Tenant not found for run.',
|
|
initiator: $initiator,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$backupSet = BackupSet::withTrashed()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->whereKey($this->backupSetId)
|
|
->first();
|
|
|
|
if (! $backupSet) {
|
|
$this->failRun(
|
|
operationRunService: $operationRunService,
|
|
tenant: $tenant,
|
|
code: 'backup_set.not_found',
|
|
message: 'Backup set not found.',
|
|
initiator: $initiator,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($backupSet->trashed()) {
|
|
$this->failRun(
|
|
operationRunService: $operationRunService,
|
|
tenant: $tenant,
|
|
code: 'backup_set.archived',
|
|
message: 'Backup set is archived.',
|
|
initiator: $initiator,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
$this->operationRun->update([
|
|
'context' => array_merge($this->operationRun->context ?? [], [
|
|
'backup_set_id' => (int) $backupSet->getKey(),
|
|
'policy_ids' => $policyIds,
|
|
'options' => [
|
|
'include_assignments' => $includeAssignments,
|
|
'include_scope_tags' => $includeScopeTags,
|
|
'include_foundations' => $includeFoundations,
|
|
],
|
|
'idempotency_key' => $this->idempotencyKey,
|
|
]),
|
|
]);
|
|
|
|
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'total' => count($policyIds),
|
|
'items' => count($policyIds),
|
|
]);
|
|
|
|
if ($policyIds === []) {
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
failures: [[
|
|
'code' => 'selection.empty',
|
|
'message' => 'No policies selected.',
|
|
]],
|
|
);
|
|
|
|
$this->notifyRunFailed($initiator, $tenant, 'No policies selected.');
|
|
|
|
return;
|
|
}
|
|
|
|
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
|
|
$newBackupFailures = [];
|
|
|
|
$didMutateBackupSet = false;
|
|
$backupSetItemMutations = 0;
|
|
$foundationMutations = 0;
|
|
$foundationFailures = 0;
|
|
|
|
/** @var array<int, int> $activePolicyIds */
|
|
$activePolicyIds = BackupItem::query()
|
|
->where('backup_set_id', $backupSet->getKey())
|
|
->whereIn('policy_id', $policyIds)
|
|
->pluck('policy_id')
|
|
->filter()
|
|
->map(fn (mixed $value): int => (int) $value)
|
|
->values()
|
|
->all();
|
|
|
|
$activePolicyIdSet = array_fill_keys($activePolicyIds, true);
|
|
|
|
/** @var EloquentCollection<int, BackupItem> $trashedItems */
|
|
$trashedItems = BackupItem::onlyTrashed()
|
|
->where('backup_set_id', $backupSet->getKey())
|
|
->whereIn('policy_id', $policyIds)
|
|
->get()
|
|
->keyBy('policy_id');
|
|
|
|
/** @var EloquentCollection<int, Policy> $policies */
|
|
$policies = Policy::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->whereIn('id', $policyIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$runFailuresForOperationRun = [];
|
|
|
|
foreach ($policyIds as $policyId) {
|
|
if (isset($activePolicyIdSet[$policyId])) {
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'skipped' => 1,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$trashed = $trashedItems->get($policyId);
|
|
|
|
if ($trashed instanceof BackupItem) {
|
|
$trashed->restore();
|
|
|
|
$activePolicyIdSet[$policyId] = true;
|
|
$didMutateBackupSet = true;
|
|
$backupSetItemMutations++;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'updated' => 1,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
$policy = $policies->get($policyId);
|
|
|
|
if (! $policy instanceof Policy) {
|
|
$newBackupFailures[] = [
|
|
'policy_id' => $policyId,
|
|
'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'),
|
|
'status' => null,
|
|
'reason_code' => 'policy_not_found',
|
|
];
|
|
$didMutateBackupSet = true;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'failed' => 1,
|
|
]);
|
|
|
|
$runFailuresForOperationRun[] = [
|
|
'code' => 'policy.not_found',
|
|
'message' => "Policy {$policyId} not found.",
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($policy->ignored_at) {
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'skipped' => 1,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$captureResult = $captureOrchestrator->capture(
|
|
policy: $policy,
|
|
tenant: $tenant,
|
|
includeAssignments: $includeAssignments,
|
|
includeScopeTags: $includeScopeTags,
|
|
createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null,
|
|
metadata: [
|
|
'source' => 'backup',
|
|
'backup_set_id' => $backupSet->getKey(),
|
|
],
|
|
);
|
|
} catch (Throwable $throwable) {
|
|
$reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
|
|
|
$newBackupFailures[] = [
|
|
'policy_id' => $policyId,
|
|
'reason' => $reason,
|
|
'status' => null,
|
|
'reason_code' => 'unknown',
|
|
];
|
|
$didMutateBackupSet = true;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'failed' => 1,
|
|
]);
|
|
|
|
$runFailuresForOperationRun[] = [
|
|
'code' => 'policy.capture_exception',
|
|
'message' => $reason,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
if (isset($captureResult['failure']) && is_array($captureResult['failure'])) {
|
|
$failure = $captureResult['failure'];
|
|
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
|
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
|
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.'));
|
|
|
|
$newBackupFailures[] = [
|
|
'policy_id' => $policyId,
|
|
'reason' => $reason,
|
|
'status' => $status,
|
|
'reason_code' => $reasonCode,
|
|
];
|
|
$didMutateBackupSet = true;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'failed' => 1,
|
|
]);
|
|
|
|
$runFailuresForOperationRun[] = [
|
|
'code' => "graph.{$reasonCode}",
|
|
'message' => $reason,
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$version = $captureResult['version'] ?? null;
|
|
$captured = $captureResult['captured'] ?? null;
|
|
|
|
if (! $version || ! is_array($captured)) {
|
|
$newBackupFailures[] = [
|
|
'policy_id' => $policyId,
|
|
'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'),
|
|
'status' => null,
|
|
'reason_code' => 'unknown',
|
|
];
|
|
$didMutateBackupSet = true;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'failed' => 1,
|
|
]);
|
|
|
|
$runFailuresForOperationRun[] = [
|
|
'code' => 'capture.missing_payload',
|
|
'message' => 'Capture result missing version payload.',
|
|
];
|
|
|
|
continue;
|
|
}
|
|
|
|
$payload = $captured['payload'] ?? [];
|
|
$metadata = is_array($captured['metadata'] ?? null) ? $captured['metadata'] : [];
|
|
$assignments = is_array($captured['assignments'] ?? null) ? $captured['assignments'] : null;
|
|
$scopeTags = is_array($captured['scope_tags'] ?? null) ? $captured['scope_tags'] : null;
|
|
|
|
if (! is_array($payload)) {
|
|
$payload = [];
|
|
}
|
|
|
|
$validation = $snapshotValidator->validate($payload);
|
|
$warnings = $validation['warnings'] ?? [];
|
|
|
|
$odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform);
|
|
|
|
if ($odataWarning) {
|
|
$warnings[] = $odataWarning;
|
|
}
|
|
|
|
if (! empty($warnings)) {
|
|
$existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : [];
|
|
$metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $warnings)));
|
|
}
|
|
|
|
if (is_array($scopeTags)) {
|
|
$metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null;
|
|
$metadata['scope_tag_names'] = $scopeTags['names'] ?? null;
|
|
}
|
|
|
|
try {
|
|
BackupItem::create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'backup_set_id' => $backupSet->getKey(),
|
|
'policy_id' => $policy->getKey(),
|
|
'policy_version_id' => $version->getKey(),
|
|
'policy_identifier' => $policy->external_id,
|
|
'policy_type' => $policy->policy_type,
|
|
'platform' => $policy->platform,
|
|
'payload' => $payload,
|
|
'metadata' => $metadata,
|
|
'assignments' => $assignments,
|
|
]);
|
|
} catch (QueryException $exception) {
|
|
if ((string) $exception->getCode() === '23505') {
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'skipped' => 1,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$activePolicyIdSet[$policyId] = true;
|
|
$didMutateBackupSet = true;
|
|
$backupSetItemMutations++;
|
|
|
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
|
'processed' => 1,
|
|
'succeeded' => 1,
|
|
'created' => 1,
|
|
]);
|
|
}
|
|
|
|
if ($includeFoundations) {
|
|
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
|
|
foundationSnapshots: $foundationSnapshots,
|
|
tenant: $tenant,
|
|
backupSet: $backupSet,
|
|
);
|
|
|
|
if (($foundationOutcome['created'] ?? 0) > 0 || ($foundationOutcome['restored'] ?? 0) > 0) {
|
|
$didMutateBackupSet = true;
|
|
$foundationMutations = (int) $foundationOutcome['created'] + (int) $foundationOutcome['restored'];
|
|
}
|
|
|
|
if ($foundationFailureEntries !== []) {
|
|
$didMutateBackupSet = true;
|
|
$foundationFailures = count($foundationFailureEntries);
|
|
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
|
|
|
|
foreach ($foundationFailureEntries as $foundationFailure) {
|
|
$runFailuresForOperationRun[] = [
|
|
'code' => 'foundation.capture_failed',
|
|
'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($didMutateBackupSet) {
|
|
$allFailures = array_merge($existingBackupFailures, $newBackupFailures);
|
|
$mutations = $backupSetItemMutations + $foundationMutations;
|
|
|
|
$backupSetStatus = match (true) {
|
|
$mutations === 0 && count($allFailures) > 0 => 'failed',
|
|
count($allFailures) > 0 => 'partial',
|
|
default => 'completed',
|
|
};
|
|
|
|
$backupSet->update([
|
|
'status' => $backupSetStatus,
|
|
'item_count' => $backupSet->items()->count(),
|
|
'completed_at' => now(),
|
|
'metadata' => ['failures' => $allFailures],
|
|
]);
|
|
}
|
|
|
|
$this->operationRun->refresh();
|
|
|
|
$counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : [];
|
|
$failed = (int) ($counts['failed'] ?? 0);
|
|
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
|
$skipped = (int) ($counts['skipped'] ?? 0);
|
|
|
|
$outcome = 'succeeded';
|
|
if ($failed > 0 && $succeeded > 0) {
|
|
$outcome = 'partially_succeeded';
|
|
}
|
|
if ($failed > 0 && $succeeded === 0) {
|
|
$outcome = 'failed';
|
|
}
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: 'completed',
|
|
outcome: $outcome,
|
|
failures: $runFailuresForOperationRun,
|
|
);
|
|
|
|
if (! $initiator instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$message = "Added {$succeeded} policies";
|
|
if ($skipped > 0) {
|
|
$message .= " ({$skipped} skipped)";
|
|
}
|
|
if ($failed > 0) {
|
|
$message .= " ({$failed} failed)";
|
|
}
|
|
|
|
if ($includeFoundations) {
|
|
$message .= ". Foundations: {$foundationMutations} items";
|
|
|
|
if ($foundationFailures > 0) {
|
|
$message .= " ({$foundationFailures} failed)";
|
|
}
|
|
}
|
|
|
|
$message .= '.';
|
|
|
|
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
|
|
|
|
$notification = Notification::make()
|
|
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
|
->body($message)
|
|
->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
|
]);
|
|
|
|
if ($partial) {
|
|
$notification->warning();
|
|
} else {
|
|
$notification->success();
|
|
}
|
|
|
|
$notification
|
|
->sendToDatabase($initiator)
|
|
->send();
|
|
} catch (Throwable $throwable) {
|
|
$this->failRun(
|
|
operationRunService: $operationRunService,
|
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
|
code: 'exception.unhandled',
|
|
message: $throwable->getMessage(),
|
|
initiator: $initiator,
|
|
);
|
|
|
|
// TrackOperationRun will catch this throw
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $policyIds
|
|
* @return array<int>
|
|
*/
|
|
private function normalizePolicyIds(array $policyIds): array
|
|
{
|
|
$policyIds = array_values(array_unique(array_map('intval', $policyIds)));
|
|
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
|
|
sort($policyIds);
|
|
|
|
return $policyIds;
|
|
}
|
|
|
|
private function failRun(
|
|
OperationRunService $operationRunService,
|
|
?Tenant $tenant,
|
|
string $code,
|
|
string $message,
|
|
?User $initiator = null,
|
|
): void {
|
|
$safeMessage = RunFailureSanitizer::sanitizeMessage($message);
|
|
$safeCode = RunFailureSanitizer::sanitizeCode($code);
|
|
|
|
$operationRunService->updateRun(
|
|
$this->operationRun,
|
|
status: 'completed',
|
|
outcome: 'failed',
|
|
failures: [[
|
|
'code' => $safeCode,
|
|
'message' => $safeMessage,
|
|
]],
|
|
);
|
|
|
|
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
|
|
}
|
|
|
|
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
|
{
|
|
if (! $initiator instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$notification = Notification::make()
|
|
->title('Add Policies Failed')
|
|
->body($reason);
|
|
|
|
if ($tenant instanceof Tenant) {
|
|
$notification->actions([
|
|
\Filament\Actions\Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
|
]);
|
|
}
|
|
|
|
$notification
|
|
->danger()
|
|
->sendToDatabase($initiator)
|
|
->send();
|
|
}
|
|
|
|
private function mapGraphFailureReasonCode(?int $status): string
|
|
{
|
|
return match (true) {
|
|
$status === 403 => 'graph_forbidden',
|
|
in_array($status, [429, 503], true) => 'graph_throttled',
|
|
in_array($status, [408, 500, 502, 504], true) => 'graph_transient',
|
|
default => 'unknown',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array{0:array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>},1:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>}
|
|
*/
|
|
private function captureFoundations(
|
|
FoundationSnapshotService $foundationSnapshots,
|
|
Tenant $tenant,
|
|
BackupSet $backupSet,
|
|
): array {
|
|
$types = config('tenantpilot.foundation_types', []);
|
|
$created = 0;
|
|
$restored = 0;
|
|
$failures = [];
|
|
|
|
foreach ($types as $typeConfig) {
|
|
$foundationType = $typeConfig['type'] ?? null;
|
|
|
|
if (! is_string($foundationType) || $foundationType === '') {
|
|
continue;
|
|
}
|
|
|
|
$result = $foundationSnapshots->fetchAll($tenant, $foundationType);
|
|
|
|
foreach (($result['failures'] ?? []) as $failure) {
|
|
if (! is_array($failure)) {
|
|
continue;
|
|
}
|
|
|
|
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
|
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
|
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.'));
|
|
|
|
$failures[] = [
|
|
'foundation_type' => $foundationType,
|
|
'reason' => $reason,
|
|
'status' => $status,
|
|
'reason_code' => $reasonCode,
|
|
];
|
|
}
|
|
|
|
foreach (($result['items'] ?? []) as $snapshot) {
|
|
if (! is_array($snapshot)) {
|
|
continue;
|
|
}
|
|
|
|
$sourceId = $snapshot['source_id'] ?? null;
|
|
|
|
if (! is_string($sourceId) || $sourceId === '') {
|
|
continue;
|
|
}
|
|
|
|
$existing = BackupItem::withTrashed()
|
|
->where('backup_set_id', $backupSet->getKey())
|
|
->where('policy_type', $foundationType)
|
|
->where('policy_identifier', $sourceId)
|
|
->first();
|
|
|
|
if ($existing) {
|
|
if ($existing->trashed()) {
|
|
$existing->restore();
|
|
$restored++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
BackupItem::create([
|
|
'tenant_id' => $tenant->getKey(),
|
|
'backup_set_id' => $backupSet->getKey(),
|
|
'policy_id' => null,
|
|
'policy_identifier' => $sourceId,
|
|
'policy_type' => $foundationType,
|
|
'platform' => $typeConfig['platform'] ?? null,
|
|
'payload' => $snapshot['payload'] ?? [],
|
|
'metadata' => $snapshot['metadata'] ?? [],
|
|
]);
|
|
|
|
$created++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
[
|
|
'created' => $created,
|
|
'restored' => $restored,
|
|
'failures' => $failures,
|
|
],
|
|
$failures,
|
|
];
|
|
}
|
|
}
|