Implements Spec 081 provider-connection cutover. Highlights: - Adds provider connection resolution + gating for operations/verification. - Adds provider credential observer wiring. - Updates Filament tenant verify flow to block with next-steps when provider connection isn’t ready. - Adds spec docs under specs/081-provider-connection-cutover/ and extensive Spec081 test coverage. Tests: - vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantSetupTest.php - Focused suites for ProviderConnections/Verification ran during implementation (see local logs). Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #98
308 lines
9.2 KiB
PHP
308 lines
9.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Verification;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Support\OpsUx\RunFailureSanitizer;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
|
|
final class VerificationReportWriter
|
|
{
|
|
/**
|
|
* @param array<int, array<string, mixed>> $checks
|
|
* @param array<string, mixed> $identity
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function write(OperationRun $run, array $checks, array $identity = []): array
|
|
{
|
|
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
|
|
|
$report = self::build($flow, $checks, $identity);
|
|
$report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run);
|
|
|
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
|
|
|
if (! VerificationReportSchema::isValidReport($report)) {
|
|
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$context['verification_report'] = $report;
|
|
|
|
$run->update(['context' => $context]);
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $checks
|
|
* @param array<string, mixed> $identity
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function build(string $flow, array $checks, array $identity = []): array
|
|
{
|
|
$flow = trim($flow);
|
|
$flow = $flow !== '' ? $flow : 'unknown';
|
|
|
|
$normalizedChecks = [];
|
|
|
|
foreach ($checks as $check) {
|
|
if (! is_array($check)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedChecks[] = self::normalizeCheckResult($check);
|
|
}
|
|
|
|
$counts = self::deriveCounts($normalizedChecks);
|
|
|
|
$report = [
|
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
|
'flow' => $flow,
|
|
'generated_at' => now()->toISOString(),
|
|
'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks),
|
|
'previous_report_id' => null,
|
|
'summary' => [
|
|
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
|
'counts' => $counts,
|
|
],
|
|
'checks' => $normalizedChecks,
|
|
];
|
|
|
|
if ($identity !== []) {
|
|
$report['identity'] = $identity;
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildFallbackReport(string $flow): array
|
|
{
|
|
return [
|
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
|
'flow' => $flow !== '' ? $flow : 'unknown',
|
|
'generated_at' => now()->toISOString(),
|
|
'fingerprint' => VerificationReportFingerprint::forChecks([]),
|
|
'previous_report_id' => null,
|
|
'summary' => [
|
|
'overall' => VerificationReportOverall::NeedsAttention->value,
|
|
'counts' => [
|
|
'total' => 0,
|
|
'pass' => 0,
|
|
'fail' => 0,
|
|
'warn' => 0,
|
|
'skip' => 0,
|
|
'running' => 0,
|
|
],
|
|
],
|
|
'checks' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $check
|
|
* @return array{
|
|
* key: string,
|
|
* title: string,
|
|
* status: string,
|
|
* severity: string,
|
|
* blocking: bool,
|
|
* reason_code: string,
|
|
* message: string,
|
|
* evidence: array<int, array{kind: string, value: int|string}>,
|
|
* next_steps: array<int, array{label: string, url: string}>
|
|
* }
|
|
*/
|
|
private static function normalizeCheckResult(array $check): array
|
|
{
|
|
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
|
|
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
|
|
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => self::normalizeCheckStatus($check['status'] ?? null),
|
|
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
|
|
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
|
|
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
|
|
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
|
|
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
|
|
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
|
|
];
|
|
}
|
|
|
|
private static function normalizeCheckStatus(mixed $status): string
|
|
{
|
|
if (! is_string($status)) {
|
|
return VerificationCheckStatus::Fail->value;
|
|
}
|
|
|
|
$status = strtolower(trim($status));
|
|
|
|
return in_array($status, VerificationCheckStatus::values(), true)
|
|
? $status
|
|
: VerificationCheckStatus::Fail->value;
|
|
}
|
|
|
|
private static function normalizeCheckSeverity(mixed $severity): string
|
|
{
|
|
if (! is_string($severity)) {
|
|
return '';
|
|
}
|
|
|
|
$severity = strtolower(trim($severity));
|
|
|
|
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
|
|
}
|
|
|
|
private static function normalizeReasonCode(mixed $reasonCode): string
|
|
{
|
|
if (! is_string($reasonCode)) {
|
|
return ProviderReasonCodes::UnknownError;
|
|
}
|
|
|
|
$reasonCode = strtolower(trim($reasonCode));
|
|
|
|
if ($reasonCode === '') {
|
|
return ProviderReasonCodes::UnknownError;
|
|
}
|
|
|
|
if (str_starts_with($reasonCode, 'ext.')) {
|
|
return $reasonCode;
|
|
}
|
|
|
|
$reasonCode = RunFailureSanitizer::normalizeReasonCode($reasonCode);
|
|
|
|
if (ProviderReasonCodes::isKnown($reasonCode)) {
|
|
return $reasonCode;
|
|
}
|
|
|
|
return in_array($reasonCode, ['ok', 'not_applicable'], true)
|
|
? $reasonCode
|
|
: ProviderReasonCodes::UnknownError;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{kind: string, value: int|string}>
|
|
*/
|
|
private static function normalizeEvidence(mixed $evidence): array
|
|
{
|
|
if (! is_array($evidence)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($evidence as $pointer) {
|
|
if (! is_array($pointer)) {
|
|
continue;
|
|
}
|
|
|
|
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
|
|
$value = $pointer['value'] ?? null;
|
|
|
|
if ($kind === null) {
|
|
continue;
|
|
}
|
|
|
|
if (! is_int($value) && ! is_string($value)) {
|
|
continue;
|
|
}
|
|
|
|
if (is_string($value) && trim($value) === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'kind' => $kind,
|
|
'value' => is_int($value) ? $value : trim($value),
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{label: string, url: string}>
|
|
*/
|
|
private static function normalizeNextSteps(mixed $steps): array
|
|
{
|
|
return VerificationReportSanitizer::sanitizeNextStepsPayload($steps);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
|
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
|
|
*/
|
|
private static function deriveCounts(array $checks): array
|
|
{
|
|
$counts = [
|
|
'total' => count($checks),
|
|
'pass' => 0,
|
|
'fail' => 0,
|
|
'warn' => 0,
|
|
'skip' => 0,
|
|
'running' => 0,
|
|
];
|
|
|
|
foreach ($checks as $check) {
|
|
$status = $check['status'] ?? null;
|
|
|
|
if (! is_string($status) || ! array_key_exists($status, $counts)) {
|
|
continue;
|
|
}
|
|
|
|
$counts[$status] += 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
|
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
|
|
*/
|
|
private static function deriveOverall(array $checks, array $counts): string
|
|
{
|
|
if (($counts['running'] ?? 0) > 0) {
|
|
return VerificationReportOverall::Running->value;
|
|
}
|
|
|
|
if (($counts['total'] ?? 0) === 0) {
|
|
return VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
foreach ($checks as $check) {
|
|
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
|
|
return VerificationReportOverall::Blocked->value;
|
|
}
|
|
}
|
|
|
|
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
|
|
return VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
return VerificationReportOverall::Ready->value;
|
|
}
|
|
|
|
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return $fallback;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
if ($value === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|