Implements Spec 096 ops polish bundle: - Persist durable OperationRun.summary_counts for assignment fetch/restore (final attempt wins) - Server-side dedupe for assignment jobs (15-minute cooldown + non-canonical skip) - Track ReconcileAdapterRunsJob via workspace-scoped OperationRun + stable failure codes + overlap prevention - Seed DX: ensure seeded tenants use UUID v4 external_id and seed satisfies workspace_id NOT NULL constraints Verification (local / evidence-based): - `vendor/bin/sail artisan test --compact tests/Feature/Operations/AssignmentRunSummaryCountsTest.php tests/Feature/Operations/AssignmentJobDedupeTest.php tests/Feature/Operations/ReconcileAdapterRunsJobTrackingTest.php tests/Feature/Seed/PoliciesSeederExternalIdTest.php` - `vendor/bin/sail bin pint --dirty` Spec artifacts included under `specs/096-ops-polish-assignment-dedupe-system-tracking/` (spec/plan/tasks/checklists). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #115
140 lines
4.9 KiB
PHP
140 lines
4.9 KiB
PHP
<?php
|
|
|
|
namespace App\Support\OpsUx;
|
|
|
|
final class AssignmentJobFingerprint
|
|
{
|
|
public static function forFetch(
|
|
int $backupItemId,
|
|
string $tenantExternalId,
|
|
string $policyExternalId,
|
|
): string {
|
|
return self::hash('assignments.fetch', [
|
|
'backup_item_id' => $backupItemId,
|
|
'tenant_external_id' => trim($tenantExternalId),
|
|
'policy_external_id' => trim($policyExternalId),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $assignments
|
|
* @param array<string, mixed> $groupMapping
|
|
* @param array<string, mixed> $foundationMapping
|
|
*/
|
|
public static function forRestore(
|
|
int $restoreRunId,
|
|
int $tenantId,
|
|
string $policyType,
|
|
string $policyId,
|
|
array $assignments,
|
|
array $groupMapping,
|
|
array $foundationMapping = [],
|
|
): string {
|
|
return self::hash('assignments.restore', [
|
|
'restore_run_id' => $restoreRunId,
|
|
'tenant_id' => $tenantId,
|
|
'policy_type' => trim($policyType),
|
|
'policy_id' => trim($policyId),
|
|
'assignments' => self::normalizeAssignments($assignments),
|
|
'group_mapping' => $groupMapping,
|
|
'foundation_mapping' => $foundationMapping,
|
|
]);
|
|
}
|
|
|
|
public static function executionIdentityKey(
|
|
string $jobType,
|
|
int $tenantId,
|
|
string $fingerprint,
|
|
?int $operationRunId = null,
|
|
): string {
|
|
if (is_int($operationRunId) && $operationRunId > 0) {
|
|
return 'operation_run:'.$operationRunId;
|
|
}
|
|
|
|
return 'tenant:'.$tenantId
|
|
.'|job_type:'.trim($jobType)
|
|
.'|fingerprint:'.trim($fingerprint);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $identityInputs
|
|
*/
|
|
private static function hash(string $jobType, array $identityInputs): string
|
|
{
|
|
$payload = [
|
|
'job_type' => trim($jobType),
|
|
'identity' => self::normalize($identityInputs),
|
|
];
|
|
|
|
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
return hash('sha256', (string) $json);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $assignments
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private static function normalizeAssignments(array $assignments): array
|
|
{
|
|
$normalized = [];
|
|
|
|
foreach ($assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$target = is_array($assignment['target'] ?? null) ? $assignment['target'] : [];
|
|
|
|
$normalized[] = [
|
|
'id' => is_scalar($assignment['id'] ?? null) ? (string) $assignment['id'] : '',
|
|
'target_type' => is_scalar($target['@odata.type'] ?? null) ? (string) $target['@odata.type'] : '',
|
|
'group_id' => is_scalar($target['groupId'] ?? null) ? (string) $target['groupId'] : '',
|
|
'assignment_filter_id' => is_scalar($assignment['deviceAndAppManagementAssignmentFilterId'] ?? null)
|
|
? (string) $assignment['deviceAndAppManagementAssignmentFilterId']
|
|
: (is_scalar($target['deviceAndAppManagementAssignmentFilterId'] ?? null) ? (string) $target['deviceAndAppManagementAssignmentFilterId'] : ''),
|
|
'assignment_filter_type' => is_scalar($assignment['deviceAndAppManagementAssignmentFilterType'] ?? null)
|
|
? (string) $assignment['deviceAndAppManagementAssignmentFilterType']
|
|
: (is_scalar($target['deviceAndAppManagementAssignmentFilterType'] ?? null) ? (string) $target['deviceAndAppManagementAssignmentFilterType'] : ''),
|
|
];
|
|
}
|
|
|
|
usort($normalized, static function (array $left, array $right): int {
|
|
$leftJson = json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$rightJson = json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
return strcmp((string) $leftJson, (string) $rightJson);
|
|
});
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private static function normalize(mixed $value): mixed
|
|
{
|
|
if (! is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (array_is_list($value)) {
|
|
$items = array_map(static fn (mixed $item): mixed => self::normalize($item), $value);
|
|
|
|
usort($items, static function (mixed $left, mixed $right): int {
|
|
$leftJson = json_encode($left, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
$rightJson = json_encode($right, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
return strcmp((string) $leftJson, (string) $rightJson);
|
|
});
|
|
|
|
return array_values($items);
|
|
}
|
|
|
|
ksort($value);
|
|
|
|
foreach ($value as $key => $item) {
|
|
$value[$key] = self::normalize($item);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|