475 lines
17 KiB
PHP
475 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\BackupQuality;
|
|
|
|
use App\Models\BackupItem;
|
|
use App\Models\BackupSet;
|
|
use App\Models\PolicyVersion;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class BackupQualityResolver
|
|
{
|
|
public function summarizeBackupSet(BackupSet $backupSet): BackupQualitySummary
|
|
{
|
|
$items = $backupSet->relationLoaded('items')
|
|
? $backupSet->items
|
|
: $backupSet->items()->get();
|
|
|
|
return $this->summarizeBackupItems(
|
|
$items,
|
|
max((int) ($backupSet->item_count ?? 0), $items->count()),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param iterable<BackupItem> $items
|
|
*/
|
|
public function summarizeBackupItems(iterable $items, ?int $totalItems = null): BackupQualitySummary
|
|
{
|
|
$itemSummaries = Collection::make($items)
|
|
->map(fn (BackupItem $item): BackupQualitySummary => $this->forBackupItem($item))
|
|
->values();
|
|
|
|
$resolvedTotalItems = max($itemSummaries->count(), (int) ($totalItems ?? 0));
|
|
$metadataOnlyCount = $itemSummaries->where('metadataOnlyCount', '>', 0)->count();
|
|
$assignmentIssueCount = $itemSummaries->where('assignmentIssueCount', '>', 0)->count();
|
|
$orphanedAssignmentCount = $itemSummaries->where('orphanedAssignmentCount', '>', 0)->count();
|
|
$integrityWarningCount = $itemSummaries->where('integrityWarningCount', '>', 0)->count();
|
|
$unknownQualityCount = $itemSummaries->where('unknownQualityCount', '>', 0)->count();
|
|
$degradedItemCount = $itemSummaries->filter(
|
|
fn (BackupQualitySummary $summary): bool => $summary->hasDegradations()
|
|
)->count();
|
|
|
|
$degradationFamilies = $this->orderedFamilies(
|
|
$itemSummaries
|
|
->flatMap(fn (BackupQualitySummary $summary): array => $summary->degradationFamilies)
|
|
->all(),
|
|
);
|
|
|
|
$qualityHighlights = $this->setHighlights(
|
|
totalItems: $resolvedTotalItems,
|
|
degradedItemCount: $degradedItemCount,
|
|
metadataOnlyCount: $metadataOnlyCount,
|
|
assignmentIssueCount: $assignmentIssueCount,
|
|
orphanedAssignmentCount: $orphanedAssignmentCount,
|
|
integrityWarningCount: $integrityWarningCount,
|
|
unknownQualityCount: $unknownQualityCount,
|
|
);
|
|
|
|
$compactSummary = $qualityHighlights === []
|
|
? $this->defaultSetCompactSummary($resolvedTotalItems)
|
|
: implode(' • ', $qualityHighlights);
|
|
|
|
$summaryMessage = match (true) {
|
|
$resolvedTotalItems === 0 => 'No backup items were captured in this set.',
|
|
$degradedItemCount === 0 => sprintf(
|
|
'No degradations were detected across %d captured item%s.',
|
|
$resolvedTotalItems,
|
|
$resolvedTotalItems === 1 ? '' : 's',
|
|
),
|
|
default => sprintf(
|
|
'%d of %d captured item%s show degraded input quality.',
|
|
$degradedItemCount,
|
|
$resolvedTotalItems,
|
|
$resolvedTotalItems === 1 ? '' : 's',
|
|
),
|
|
};
|
|
|
|
$nextAction = match (true) {
|
|
$resolvedTotalItems === 0 => 'Create or refresh a backup set before starting a restore review.',
|
|
$degradedItemCount > 0 => 'Open the backup set detail and inspect degraded items before continuing into restore.',
|
|
default => 'Open the backup set detail to verify item-level context before relying on it for restore work.',
|
|
};
|
|
|
|
return new BackupQualitySummary(
|
|
kind: 'backup_set',
|
|
snapshotMode: $this->aggregateSnapshotMode($resolvedTotalItems, $metadataOnlyCount, $unknownQualityCount),
|
|
totalItems: $resolvedTotalItems,
|
|
degradedItemCount: $degradedItemCount,
|
|
metadataOnlyCount: $metadataOnlyCount,
|
|
assignmentIssueCount: $assignmentIssueCount,
|
|
orphanedAssignmentCount: $orphanedAssignmentCount,
|
|
integrityWarningCount: $integrityWarningCount,
|
|
unknownQualityCount: $unknownQualityCount,
|
|
hasAssignmentIssues: $assignmentIssueCount > 0,
|
|
hasOrphanedAssignments: $orphanedAssignmentCount > 0,
|
|
assignmentCaptureReason: null,
|
|
integrityWarning: null,
|
|
degradationFamilies: $degradationFamilies,
|
|
qualityHighlights: $qualityHighlights,
|
|
compactSummary: $compactSummary,
|
|
summaryMessage: $summaryMessage,
|
|
nextAction: $nextAction,
|
|
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
|
);
|
|
}
|
|
|
|
public function forBackupItem(BackupItem $backupItem): BackupQualitySummary
|
|
{
|
|
$snapshotMode = $this->resolveSnapshotMode(
|
|
source: $backupItem->snapshotSource(),
|
|
warnings: $backupItem->warningMessages(),
|
|
hasCapturedPayload: $backupItem->hasCapturedPayload(),
|
|
);
|
|
|
|
$assignmentCaptureReason = $backupItem->assignmentCaptureReason();
|
|
$integrityWarning = $backupItem->integrityWarning();
|
|
$hasAssignmentIssues = $backupItem->assignmentsFetchFailed();
|
|
$hasOrphanedAssignments = $backupItem->hasOrphanedAssignments();
|
|
|
|
$degradationFamilies = $this->singleRecordFamilies(
|
|
snapshotMode: $snapshotMode,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
integrityWarning: $integrityWarning,
|
|
);
|
|
|
|
$qualityHighlights = $this->singleRecordHighlights(
|
|
snapshotMode: $snapshotMode,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
integrityWarning: $integrityWarning,
|
|
assignmentCaptureReason: $assignmentCaptureReason,
|
|
);
|
|
|
|
return new BackupQualitySummary(
|
|
kind: 'backup_item',
|
|
snapshotMode: $snapshotMode,
|
|
totalItems: 1,
|
|
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
|
|
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
|
|
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
|
|
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
|
|
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
|
|
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
assignmentCaptureReason: $assignmentCaptureReason,
|
|
integrityWarning: $integrityWarning,
|
|
degradationFamilies: $degradationFamilies,
|
|
qualityHighlights: $qualityHighlights,
|
|
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
|
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
|
nextAction: $degradationFamilies === []
|
|
? 'Open the linked detail if you need deeper restore context.'
|
|
: 'Inspect the linked detail before relying on this backup item for restore.',
|
|
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
|
);
|
|
}
|
|
|
|
public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySummary
|
|
{
|
|
$snapshotMode = $this->resolveSnapshotMode(
|
|
source: $policyVersion->snapshotSource(),
|
|
warnings: $policyVersion->warningMessages(),
|
|
hasCapturedPayload: $policyVersion->hasCapturedPayload(),
|
|
);
|
|
|
|
$integrityWarning = $policyVersion->integrityWarning();
|
|
$hasAssignmentIssues = $policyVersion->assignmentsFetchFailed();
|
|
$hasOrphanedAssignments = $policyVersion->hasOrphanedAssignments();
|
|
|
|
$degradationFamilies = $this->singleRecordFamilies(
|
|
snapshotMode: $snapshotMode,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
integrityWarning: $integrityWarning,
|
|
);
|
|
|
|
$qualityHighlights = $this->singleRecordHighlights(
|
|
snapshotMode: $snapshotMode,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
integrityWarning: $integrityWarning,
|
|
);
|
|
|
|
return new BackupQualitySummary(
|
|
kind: 'policy_version',
|
|
snapshotMode: $snapshotMode,
|
|
totalItems: 1,
|
|
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
|
|
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
|
|
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
|
|
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
|
|
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
|
|
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
|
|
hasAssignmentIssues: $hasAssignmentIssues,
|
|
hasOrphanedAssignments: $hasOrphanedAssignments,
|
|
assignmentCaptureReason: null,
|
|
integrityWarning: $integrityWarning,
|
|
degradationFamilies: $degradationFamilies,
|
|
qualityHighlights: $qualityHighlights,
|
|
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
|
|
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
|
|
nextAction: $degradationFamilies === []
|
|
? 'Open the version detail if you need raw settings or diff context.'
|
|
: 'Prefer a stronger version or inspect the version detail before restore.',
|
|
positiveClaimBoundary: $this->positiveClaimBoundary(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $warnings
|
|
*/
|
|
private function resolveSnapshotMode(?string $source, array $warnings, bool $hasCapturedPayload): string
|
|
{
|
|
if ($source === 'metadata_only' || $this->warningsIndicateMetadataOnly($warnings)) {
|
|
return 'metadata_only';
|
|
}
|
|
|
|
if ($hasCapturedPayload) {
|
|
return 'full';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $warnings
|
|
*/
|
|
private function warningsIndicateMetadataOnly(array $warnings): bool
|
|
{
|
|
return Collection::make($warnings)
|
|
->contains(function (mixed $warning): bool {
|
|
if (! is_string($warning)) {
|
|
return false;
|
|
}
|
|
|
|
$normalized = Str::lower($warning);
|
|
|
|
return str_contains($normalized, 'metadata')
|
|
&& (
|
|
str_contains($normalized, 'only')
|
|
|| str_contains($normalized, 'fallback')
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function singleRecordFamilies(
|
|
string $snapshotMode,
|
|
bool $hasAssignmentIssues,
|
|
bool $hasOrphanedAssignments,
|
|
?string $integrityWarning,
|
|
): array {
|
|
$families = [];
|
|
|
|
if ($snapshotMode === 'metadata_only') {
|
|
$families[] = 'metadata_only';
|
|
}
|
|
|
|
if ($hasAssignmentIssues) {
|
|
$families[] = 'assignment_capture_issue';
|
|
}
|
|
|
|
if ($hasOrphanedAssignments) {
|
|
$families[] = 'orphaned_assignments';
|
|
}
|
|
|
|
if ($integrityWarning !== null) {
|
|
$families[] = 'integrity_warning';
|
|
}
|
|
|
|
if ($families === [] && $snapshotMode === 'unknown') {
|
|
$families[] = 'unknown_quality';
|
|
}
|
|
|
|
return $this->orderedFamilies($families);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function singleRecordHighlights(
|
|
string $snapshotMode,
|
|
bool $hasAssignmentIssues,
|
|
bool $hasOrphanedAssignments,
|
|
?string $integrityWarning,
|
|
?string $assignmentCaptureReason = null,
|
|
): array {
|
|
$highlights = [];
|
|
|
|
if ($snapshotMode === 'metadata_only') {
|
|
$highlights[] = 'Metadata only';
|
|
}
|
|
|
|
if ($hasAssignmentIssues) {
|
|
$highlights[] = 'Assignment fetch failed';
|
|
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
|
|
$highlights[] = 'Assignments captured separately';
|
|
}
|
|
|
|
if ($hasOrphanedAssignments) {
|
|
$highlights[] = 'Orphaned assignments';
|
|
}
|
|
|
|
if ($integrityWarning !== null) {
|
|
$highlights[] = 'Integrity warning';
|
|
}
|
|
|
|
if ($snapshotMode === 'unknown' && $highlights === []) {
|
|
$highlights[] = 'Unknown quality';
|
|
}
|
|
|
|
return array_values(array_unique($highlights));
|
|
}
|
|
|
|
private function compactSummaryFromHighlights(array $qualityHighlights, string $snapshotMode): string
|
|
{
|
|
if ($qualityHighlights !== []) {
|
|
return implode(' • ', $qualityHighlights);
|
|
}
|
|
|
|
return match ($snapshotMode) {
|
|
'full' => 'Full payload',
|
|
'unknown' => 'Unknown quality',
|
|
default => 'No degradations detected',
|
|
};
|
|
}
|
|
|
|
private function singleRecordSummaryMessage(array $qualityHighlights, string $snapshotMode): string
|
|
{
|
|
if ($qualityHighlights === []) {
|
|
return match ($snapshotMode) {
|
|
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
|
|
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
|
|
default => 'No degradations were detected.',
|
|
};
|
|
}
|
|
|
|
return implode(' • ', $qualityHighlights).'.';
|
|
}
|
|
|
|
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
|
|
{
|
|
if ($totalItems === 0) {
|
|
return 'unknown';
|
|
}
|
|
|
|
if ($metadataOnlyCount === $totalItems) {
|
|
return 'metadata_only';
|
|
}
|
|
|
|
if ($metadataOnlyCount === 0 && $unknownQualityCount === 0) {
|
|
return 'full';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function orderedFamilies(array $families): array
|
|
{
|
|
$weights = [
|
|
'metadata_only' => 10,
|
|
'assignment_capture_issue' => 20,
|
|
'orphaned_assignments' => 30,
|
|
'integrity_warning' => 40,
|
|
'unknown_quality' => 50,
|
|
];
|
|
|
|
$families = array_values(array_unique(array_filter(
|
|
$families,
|
|
static fn (mixed $family): bool => is_string($family) && $family !== '',
|
|
)));
|
|
|
|
usort($families, static function (string $left, string $right) use ($weights): int {
|
|
return ($weights[$left] ?? 999) <=> ($weights[$right] ?? 999);
|
|
});
|
|
|
|
return $families;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function setHighlights(
|
|
int $totalItems,
|
|
int $degradedItemCount,
|
|
int $metadataOnlyCount,
|
|
int $assignmentIssueCount,
|
|
int $orphanedAssignmentCount,
|
|
int $integrityWarningCount,
|
|
int $unknownQualityCount,
|
|
): array {
|
|
if ($totalItems === 0) {
|
|
return [];
|
|
}
|
|
|
|
$highlights = [];
|
|
|
|
if ($degradedItemCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d degraded item%s',
|
|
$degradedItemCount,
|
|
$degradedItemCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
if ($metadataOnlyCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d metadata-only',
|
|
$metadataOnlyCount,
|
|
);
|
|
}
|
|
|
|
if ($assignmentIssueCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d assignment issue%s',
|
|
$assignmentIssueCount,
|
|
$assignmentIssueCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
if ($orphanedAssignmentCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d orphaned assignment%s',
|
|
$orphanedAssignmentCount,
|
|
$orphanedAssignmentCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
if ($integrityWarningCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d integrity warning%s',
|
|
$integrityWarningCount,
|
|
$integrityWarningCount === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
if ($unknownQualityCount > 0) {
|
|
$highlights[] = sprintf(
|
|
'%d unknown quality',
|
|
$unknownQualityCount,
|
|
);
|
|
}
|
|
|
|
return $highlights;
|
|
}
|
|
|
|
private function defaultSetCompactSummary(int $totalItems): string
|
|
{
|
|
if ($totalItems === 0) {
|
|
return 'No items captured';
|
|
}
|
|
|
|
return sprintf(
|
|
'No degradations detected across %d item%s',
|
|
$totalItems,
|
|
$totalItems === 1 ? '' : 's',
|
|
);
|
|
}
|
|
|
|
private function positiveClaimBoundary(): string
|
|
{
|
|
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
|
|
}
|
|
}
|