Automated PR created by Copilot: adds implementation and tests for specs/264 cross-tenant promotion execution. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #320
272 lines
9.5 KiB
PHP
272 lines
9.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\PortfolioCompare;
|
|
|
|
use DomainException;
|
|
use InvalidArgumentException;
|
|
|
|
final class CrossTenantPromotionExecutionPlanner
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $preview
|
|
* @param array<string, mixed> $preflight
|
|
* @return array{
|
|
* selection: array<string, mixed>,
|
|
* summary: array{total: int, ready: int, excluded: int, skipped: int, created: int, updated: int},
|
|
* items: list<array<string, mixed>>,
|
|
* excluded: list<array<string, mixed>>,
|
|
* identity: array<string, mixed>
|
|
* }
|
|
*/
|
|
public function build(array $preview, array $preflight): array
|
|
{
|
|
$previewSelection = $this->selection($preview);
|
|
$preflightSelection = $this->selection($preflight);
|
|
|
|
if ($previewSelection !== $preflightSelection) {
|
|
throw new InvalidArgumentException('Promotion preflight is stale. Regenerate the preflight before execution.');
|
|
}
|
|
|
|
$items = [];
|
|
$excluded = $this->excludedSubjects($preflight);
|
|
|
|
foreach ($this->readySubjects($preflight) as $subject) {
|
|
$item = $this->executionItem($subject);
|
|
|
|
if ($item === null) {
|
|
$excluded[] = $this->excludedSubject($subject, 'source_policy_version_missing');
|
|
|
|
continue;
|
|
}
|
|
|
|
$items[] = $item;
|
|
}
|
|
|
|
$items = $this->sortItems($items);
|
|
$excluded = $this->sortItems($excluded);
|
|
|
|
if ($items === []) {
|
|
throw new DomainException('Promotion preflight has no executable ready subjects.');
|
|
}
|
|
|
|
$summary = [
|
|
'total' => count($items) + count($excluded),
|
|
'ready' => count($items),
|
|
'excluded' => count($excluded),
|
|
'skipped' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'skip_aligned')),
|
|
'created' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'create_missing')),
|
|
'updated' => count(array_filter($items, static fn (array $item): bool => ($item['execution_action'] ?? null) === 'update_existing')),
|
|
];
|
|
|
|
return [
|
|
'selection' => $previewSelection,
|
|
'summary' => $summary,
|
|
'items' => $items,
|
|
'excluded' => $excluded,
|
|
'identity' => $this->identity($previewSelection, $items),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{sourceTenantId: ?int, targetTenantId: ?int, policyTypes: list<string>}
|
|
*/
|
|
private function selection(array $payload): array
|
|
{
|
|
$selection = is_array($payload['selection'] ?? null) ? $payload['selection'] : [];
|
|
$policyTypes = is_array($selection['policyTypes'] ?? null) ? $selection['policyTypes'] : [];
|
|
|
|
$policyTypes = array_values(array_unique(array_filter(array_map(
|
|
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
|
$policyTypes,
|
|
), static fn (string $value): bool => $value !== '')));
|
|
|
|
sort($policyTypes);
|
|
|
|
return [
|
|
'sourceTenantId' => is_numeric($selection['sourceTenantId'] ?? null) ? (int) $selection['sourceTenantId'] : null,
|
|
'targetTenantId' => is_numeric($selection['targetTenantId'] ?? null) ? (int) $selection['targetTenantId'] : null,
|
|
'policyTypes' => $policyTypes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $preflight
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function readySubjects(array $preflight): array
|
|
{
|
|
$subjects = data_get($preflight, 'buckets.ready', []);
|
|
|
|
if (! is_array($subjects)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter($subjects, 'is_array'));
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $preflight
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function excludedSubjects(array $preflight): array
|
|
{
|
|
$excluded = [];
|
|
|
|
foreach (['blocked', 'manual_mapping_required'] as $bucket) {
|
|
$subjects = data_get($preflight, 'buckets.'.$bucket, []);
|
|
|
|
if (! is_array($subjects)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($subjects as $subject) {
|
|
if (! is_array($subject)) {
|
|
continue;
|
|
}
|
|
|
|
$excluded[] = $this->excludedSubject($subject, $bucket);
|
|
}
|
|
}
|
|
|
|
return $excluded;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function executionItem(array $subject): ?array
|
|
{
|
|
$policyVersionId = data_get($subject, 'source.evidence.policyVersionId');
|
|
|
|
if (! is_numeric($policyVersionId)) {
|
|
return null;
|
|
}
|
|
|
|
$state = is_string($subject['state'] ?? null) ? (string) $subject['state'] : 'blocked';
|
|
$action = match ($state) {
|
|
'match' => 'skip_aligned',
|
|
'missing' => 'create_missing',
|
|
default => 'update_existing',
|
|
};
|
|
|
|
return [
|
|
'policy_type' => $this->stringValue($subject, 'policyType'),
|
|
'display_name' => $this->stringValue($subject, 'displayName'),
|
|
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
|
'compare_state' => $state,
|
|
'execution_action' => $action,
|
|
'readiness_reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
|
'source' => [
|
|
'tenant_id' => $this->intValue(data_get($subject, 'source.tenantId')),
|
|
'inventory_item_id' => $this->intValue(data_get($subject, 'source.inventoryItemId')),
|
|
'subject_external_id' => $this->nullableString(data_get($subject, 'source.subjectExternalId')),
|
|
'policy_version_id' => (int) $policyVersionId,
|
|
'evidence_hash' => $this->nullableString(data_get($subject, 'source.evidence.hash')),
|
|
],
|
|
'target' => [
|
|
'tenant_id' => $this->intValue(data_get($subject, 'target.tenantId')),
|
|
'inventory_item_id' => $this->intValue(data_get($subject, 'target.inventoryItemId')),
|
|
'subject_external_id' => $this->nullableString(data_get($subject, 'target.subjectExternalId')),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function excludedSubject(array $subject, string $reason): array
|
|
{
|
|
return [
|
|
'policy_type' => $this->stringValue($subject, 'policyType'),
|
|
'display_name' => $this->stringValue($subject, 'displayName'),
|
|
'subject_key' => $this->stringValue($subject, 'subjectKey'),
|
|
'compare_state' => $this->stringValue($subject, 'state'),
|
|
'excluded_reason' => $reason,
|
|
'reason_codes' => $this->stringList(data_get($subject, 'preflight.reasonCodes', [])),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $items
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function sortItems(array $items): array
|
|
{
|
|
usort($items, static function (array $left, array $right): int {
|
|
return [
|
|
(string) ($left['policy_type'] ?? ''),
|
|
(string) ($left['subject_key'] ?? ''),
|
|
(string) ($left['display_name'] ?? ''),
|
|
] <=> [
|
|
(string) ($right['policy_type'] ?? ''),
|
|
(string) ($right['subject_key'] ?? ''),
|
|
(string) ($right['display_name'] ?? ''),
|
|
];
|
|
});
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $selection
|
|
* @param list<array<string, mixed>> $items
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function identity(array $selection, array $items): array
|
|
{
|
|
return [
|
|
'source_tenant_id' => $selection['sourceTenantId'] ?? null,
|
|
'target_tenant_id' => $selection['targetTenantId'] ?? null,
|
|
'policy_types' => $selection['policyTypes'] ?? [],
|
|
'subjects' => array_map(static fn (array $item): array => [
|
|
'policy_type' => $item['policy_type'] ?? '',
|
|
'subject_key' => $item['subject_key'] ?? '',
|
|
'source_policy_version_id' => data_get($item, 'source.policy_version_id'),
|
|
'source_evidence_hash' => data_get($item, 'source.evidence_hash'),
|
|
'target_subject_external_id' => data_get($item, 'target.subject_external_id'),
|
|
'execution_action' => $item['execution_action'] ?? '',
|
|
], $items),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
*/
|
|
private function stringValue(array $subject, string $key): string
|
|
{
|
|
$value = $subject[$key] ?? null;
|
|
|
|
return is_string($value) ? $value : '';
|
|
}
|
|
|
|
private function nullableString(mixed $value): ?string
|
|
{
|
|
return is_string($value) && trim($value) !== '' ? trim($value) : null;
|
|
}
|
|
|
|
private function intValue(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function stringList(mixed $values): array
|
|
{
|
|
if (! is_array($values)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(array_map(
|
|
static fn (mixed $value): string => is_string($value) ? trim($value) : '',
|
|
$values,
|
|
), static fn (string $value): bool => $value !== ''));
|
|
}
|
|
}
|