410 lines
15 KiB
PHP
410 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Support\OperationCatalog;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\Operations\OperationLifecyclePolicy;
|
|
use App\Support\Operations\OperationRunFreshnessState;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Support\Arr;
|
|
|
|
class OperationRun extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $guarded = [];
|
|
|
|
protected $casts = [
|
|
'summary_counts' => 'array',
|
|
'failure_summary' => 'array',
|
|
'context' => 'array',
|
|
'started_at' => 'datetime',
|
|
'completed_at' => 'datetime',
|
|
];
|
|
|
|
protected static function booted(): void
|
|
{
|
|
static::creating(function (self $operationRun): void {
|
|
if ($operationRun->workspace_id !== null) {
|
|
return;
|
|
}
|
|
|
|
if ($operationRun->tenant_id === null) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::query()->whereKey((int) $operationRun->tenant_id)->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
if ($tenant->workspace_id === null) {
|
|
return;
|
|
}
|
|
|
|
$operationRun->workspace_id = (int) $tenant->workspace_id;
|
|
});
|
|
}
|
|
|
|
public function tenant(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Tenant::class)->withTrashed();
|
|
}
|
|
|
|
public function workspace(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Workspace::class);
|
|
}
|
|
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
public function scopeActive(Builder $query): Builder
|
|
{
|
|
return $query->whereIn('status', [
|
|
OperationRunStatus::Queued->value,
|
|
OperationRunStatus::Running->value,
|
|
]);
|
|
}
|
|
|
|
public function scopeTerminalFailure(Builder $query): Builder
|
|
{
|
|
return $query
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->where('outcome', OperationRunOutcome::Failed->value);
|
|
}
|
|
|
|
public function scopeLikelyStale(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
|
{
|
|
$policy ??= app(OperationLifecyclePolicy::class);
|
|
|
|
return $query
|
|
->active()
|
|
->where(function (Builder $query) use ($policy): void {
|
|
foreach ($policy->coveredTypeNames() as $type) {
|
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
$typeQuery
|
|
->where('type', $type)
|
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
$stateQuery
|
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
$queuedQuery
|
|
->where('status', OperationRunStatus::Queued->value)
|
|
->whereNull('started_at')
|
|
->where('created_at', '<=', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
})
|
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
$runningQuery
|
|
->where('status', OperationRunStatus::Running->value)
|
|
->where(function (Builder $startedAtQuery) use ($policy, $type): void {
|
|
$startedAtQuery
|
|
->where('started_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
$fallbackQuery
|
|
->whereNull('started_at')
|
|
->where('created_at', '<=', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public function scopeHealthyActive(Builder $query, ?OperationLifecyclePolicy $policy = null): Builder
|
|
{
|
|
$policy ??= app(OperationLifecyclePolicy::class);
|
|
$coveredTypes = $policy->coveredTypeNames();
|
|
|
|
if ($coveredTypes === []) {
|
|
return $query->active();
|
|
}
|
|
|
|
return $query
|
|
->active()
|
|
->where(function (Builder $query) use ($coveredTypes, $policy): void {
|
|
$query->whereNotIn('type', $coveredTypes);
|
|
|
|
foreach ($coveredTypes as $type) {
|
|
$query->orWhere(function (Builder $typeQuery) use ($policy, $type): void {
|
|
$typeQuery
|
|
->where('type', $type)
|
|
->where(function (Builder $stateQuery) use ($policy, $type): void {
|
|
$stateQuery
|
|
->where(function (Builder $queuedQuery) use ($policy, $type): void {
|
|
$queuedQuery
|
|
->where('status', OperationRunStatus::Queued->value)
|
|
->where(function (Builder $freshQueuedQuery) use ($policy, $type): void {
|
|
$freshQueuedQuery
|
|
->whereNotNull('started_at')
|
|
->orWhereNull('created_at')
|
|
->orWhere('created_at', '>', now()->subSeconds($policy->queuedStaleAfterSeconds($type)));
|
|
});
|
|
})
|
|
->orWhere(function (Builder $runningQuery) use ($policy, $type): void {
|
|
$runningQuery
|
|
->where('status', OperationRunStatus::Running->value)
|
|
->where(function (Builder $freshRunningQuery) use ($policy, $type): void {
|
|
$freshRunningQuery
|
|
->where('started_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)))
|
|
->orWhere(function (Builder $fallbackQuery) use ($policy, $type): void {
|
|
$fallbackQuery
|
|
->whereNull('started_at')
|
|
->where(function (Builder $createdAtQuery) use ($policy, $type): void {
|
|
$createdAtQuery
|
|
->whereNull('created_at')
|
|
->orWhere('created_at', '>', now()->subSeconds($policy->runningStaleAfterSeconds($type)));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
public function scopeDashboardNeedsFollowUp(Builder $query): Builder
|
|
{
|
|
return $query->where(function (Builder $query): void {
|
|
$query
|
|
->where(function (Builder $terminalQuery): void {
|
|
$terminalQuery
|
|
->where('status', OperationRunStatus::Completed->value)
|
|
->whereIn('outcome', [
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
OperationRunOutcome::Failed->value,
|
|
]);
|
|
})
|
|
->orWhere(function (Builder $activeQuery): void {
|
|
$activeQuery->likelyStale();
|
|
});
|
|
});
|
|
}
|
|
|
|
public function getSelectionHashAttribute(): ?string
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return isset($context['selection_hash']) && is_string($context['selection_hash'])
|
|
? $context['selection_hash']
|
|
: null;
|
|
}
|
|
|
|
public function setSelectionHashAttribute(?string $value): void
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$context['selection_hash'] = $value;
|
|
|
|
$this->context = $context;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getSelectionPayloadAttribute(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return Arr::only($context, [
|
|
'policy_types',
|
|
'categories',
|
|
'include_foundations',
|
|
'include_dependencies',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $value
|
|
*/
|
|
public function setSelectionPayloadAttribute(?array $value): void
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
if (is_array($value)) {
|
|
$context = array_merge($context, Arr::only($value, [
|
|
'policy_types',
|
|
'categories',
|
|
'include_foundations',
|
|
'include_dependencies',
|
|
]));
|
|
}
|
|
|
|
$this->context = $context;
|
|
}
|
|
|
|
public function getFinishedAtAttribute(): mixed
|
|
{
|
|
return $this->completed_at;
|
|
}
|
|
|
|
public function setFinishedAtAttribute(mixed $value): void
|
|
{
|
|
$this->completed_at = $value;
|
|
}
|
|
|
|
public function isGovernanceArtifactOperation(): bool
|
|
{
|
|
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);
|
|
}
|
|
|
|
public function supportsOperatorExplanation(): bool
|
|
{
|
|
return OperationCatalog::supportsOperatorExplanation((string) $this->type);
|
|
}
|
|
|
|
public function governanceArtifactFamily(): ?string
|
|
{
|
|
return OperationCatalog::governanceArtifactFamily((string) $this->type);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function artifactResultContext(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$result = is_array($context['result'] ?? null) ? $context['result'] : [];
|
|
|
|
return array_merge($context, ['result' => $result]);
|
|
}
|
|
|
|
public function relatedArtifactId(): ?int
|
|
{
|
|
return match ($this->governanceArtifactFamily()) {
|
|
'baseline_snapshot' => is_numeric(data_get($this->context, 'result.snapshot_id'))
|
|
? (int) data_get($this->context, 'result.snapshot_id')
|
|
: null,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function reconciliation(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
$reconciliation = $context['reconciliation'] ?? null;
|
|
|
|
return is_array($reconciliation) ? $reconciliation : [];
|
|
}
|
|
|
|
public function isLifecycleReconciled(): bool
|
|
{
|
|
return $this->reconciliation() !== [];
|
|
}
|
|
|
|
public function lifecycleReconciliationReasonCode(): ?string
|
|
{
|
|
$reasonCode = $this->reconciliation()['reason_code'] ?? null;
|
|
|
|
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
|
}
|
|
|
|
public function freshnessState(): OperationRunFreshnessState
|
|
{
|
|
return OperationRunFreshnessState::forRun($this);
|
|
}
|
|
|
|
public function requiresDashboardFollowUp(): bool
|
|
{
|
|
if ((string) $this->status === OperationRunStatus::Completed->value) {
|
|
return in_array((string) $this->outcome, [
|
|
OperationRunOutcome::Blocked->value,
|
|
OperationRunOutcome::PartiallySucceeded->value,
|
|
OperationRunOutcome::Failed->value,
|
|
], true);
|
|
}
|
|
|
|
return $this->freshnessState()->isLikelyStale();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function baselineGapEnvelope(): array
|
|
{
|
|
$context = is_array($this->context) ? $this->context : [];
|
|
|
|
return match ((string) $this->type) {
|
|
'baseline_compare' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
|
? data_get($context, 'baseline_compare.evidence_gaps')
|
|
: [],
|
|
'baseline_capture' => is_array(data_get($context, 'baseline_capture.gaps'))
|
|
? data_get($context, 'baseline_capture.gaps')
|
|
: [],
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
public function hasStructuredBaselineGapPayload(): bool
|
|
{
|
|
$subjects = $this->baselineGapEnvelope()['subjects'] ?? null;
|
|
|
|
if (! is_array($subjects) || ! array_is_list($subjects) || $subjects === []) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($subjects as $subject) {
|
|
if (! is_array($subject)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ([
|
|
'policy_type',
|
|
'subject_key',
|
|
'subject_class',
|
|
'resolution_path',
|
|
'resolution_outcome',
|
|
'reason_code',
|
|
'operator_action_category',
|
|
'structural',
|
|
'retryable',
|
|
] as $key) {
|
|
if (! array_key_exists($key, $subject)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function hasLegacyBaselineGapPayload(): bool
|
|
{
|
|
$envelope = $this->baselineGapEnvelope();
|
|
$byReason = is_array($envelope['by_reason'] ?? null) ? $envelope['by_reason'] : [];
|
|
|
|
if (array_key_exists('policy_not_found', $byReason)) {
|
|
return true;
|
|
}
|
|
|
|
$subjects = $envelope['subjects'] ?? null;
|
|
|
|
if (! is_array($subjects)) {
|
|
return false;
|
|
}
|
|
|
|
if (! array_is_list($subjects)) {
|
|
return $subjects !== [];
|
|
}
|
|
|
|
if ($subjects === []) {
|
|
return false;
|
|
}
|
|
|
|
return ! $this->hasStructuredBaselineGapPayload();
|
|
}
|
|
}
|