054-unify-runs-suitewide (#63)
Summary Kurz: Implementiert Feature 054 — canonical OperationRun-flow, Monitoring UI, dispatch-safety, notifications, dedupe, plus small UX safety clarifications (RBAC group search delegated; Restore group mapping DB-only). What Changed Core service: OperationRun lifecycle, dedupe and dispatch helpers — OperationRunService.php. Model + migration: OperationRun model and migration — OperationRun.php, 2026_01_16_180642_create_operation_runs_table.php. Notifications: queued + terminal DB notifications (initiator-only) — OperationRunQueued.php, OperationRunCompleted.php. Monitoring UI: Filament list/detail + Livewire pieces (DB-only render) — OperationRunResource.php and related pages/views. Start surfaces / Jobs: instrumented start surfaces, job middleware, and job updates to use canonical runs — multiple app/Jobs/* and app/Filament/* updates (see tests for full coverage). RBAC + Restore UX clarifications: RBAC group search is delegated-Graph-based and disabled without delegated token; Restore group mapping remains DB-only (directory cache) and helper text always visible — TenantResource.php, RestoreRunResource.php. Specs / Constitution: updated spec & quickstart and added one-line constitution guideline about Graph usage: spec.md quickstart.md constitution.md Tests & Verification Unit / Feature tests added/updated for run lifecycle, notifications, idempotency, and UI guards: see tests/Feature/* (notably OperationRunServiceTest, MonitoringOperationsTest, OperationRunNotificationTest, and various Filament feature tests). Full test run locally: ./vendor/bin/sail artisan test → 587 passed, 5 skipped. Migrations Adds create_operation_runs_table migration; run php artisan migrate in staging after review. Notes / Rationale Monitoring pages are explicitly DB-only at render time (no Graph calls). Start surfaces enqueue work only and return a “View run” link. Delegated Graph access is used only for explicit user actions (RBAC group search); restore mapping intentionally uses cached DB data only to avoid render-time Graph calls. Dispatch wrapper marks runs failed immediately if background dispatch throws synchronously to avoid misleading “queued” states. Upgrade / Deploy Considerations Run migrations: ./vendor/bin/sail artisan migrate. Background workers should be running to process queued jobs (recommended to monitor queue health during rollout). No secret or token persistence changes. PR checklist Tests updated/added for changed behavior Specs updated: 054-unify-runs-suitewide docs + quickstart Constitution note added (.specify) Pint formatting applied Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #63
This commit is contained in:
parent
30ad57baab
commit
3030dd9af2
@ -2,6 +2,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
public/build/
|
public/build/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
vendor/
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.0.0 → 1.1.0
|
- Version change: 1.2.0 → 1.2.1
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Safety-First Restore → Read/Write Separation by Default
|
- Operations / Run Observability Standard (clarify AuditLog vs OperationRun)
|
||||||
- Auditability & Tenant Isolation → Tenant Isolation is Non-negotiable (+ Least Privilege)
|
- Added sections: None
|
||||||
- Graph Abstraction & Contracts → Single Contract Path to Graph
|
- Removed sections: None
|
||||||
- Added principles:
|
|
||||||
- Inventory-first, Snapshots-second
|
|
||||||
- Deterministic Capabilities
|
|
||||||
- Automation must be Idempotent & Observable
|
|
||||||
- Data Minimization & Safe Logging
|
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
- ✅ .specify/templates/tasks-template.md
|
- ✅ .specify/templates/tasks-template.md
|
||||||
@ -48,11 +43,29 @@ ### Tenant Isolation is Non-negotiable
|
|||||||
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
- Cross-tenant views (MSP/Platform) MUST be explicit, access-checked, and aggregation-based (no ID-based shortcuts).
|
||||||
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected.
|
||||||
|
|
||||||
### Automation must be Idempotent & Observable
|
### Operations / Run Observability Standard
|
||||||
|
- Every long-running or operationally relevant action MUST be observable, deduplicated, and auditable via Monitoring → Operations.
|
||||||
|
- An action MUST create/reuse a canonical `OperationRun` and execute asynchronously when any of the following applies:
|
||||||
|
1. It can take > 2 seconds under normal conditions.
|
||||||
|
2. It performs remote/external calls (e.g., Microsoft Graph).
|
||||||
|
3. It is queued or scheduled.
|
||||||
|
4. It is operationally relevant for troubleshooting/audit (“what ran, who started it, did it succeed, what failed?”).
|
||||||
|
- Actions that are DB-only and typically complete in < 2 seconds MAY skip `OperationRun`.
|
||||||
|
- If an action is security-relevant or affects operational behavior (e.g., “Ignore policy”), it MUST write an `AuditLog` entry
|
||||||
|
including actor, tenant, action, target, before/after, and timestamp.
|
||||||
|
- The `OperationRun` record is the canonical source of truth for Monitoring (status, timestamps, counts, failures),
|
||||||
|
even if implemented by multiple jobs/steps (“umbrella run”).
|
||||||
|
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
|
||||||
|
- Monitoring pages MUST be DB-only at render time (no external calls).
|
||||||
|
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
|
||||||
|
confirm + “View run”.
|
||||||
|
- Active-run dedupe MUST be enforced at DB level (partial unique index/constraint for active states).
|
||||||
|
- Failures MUST be stored as stable reason codes + sanitized messages; never persist secrets/tokens/PII/raw payload dumps
|
||||||
|
in failures or notifications.
|
||||||
|
- Graph calls are allowed only via explicit user interaction and only when delegated auth is present; never as a render side-effect (restore group mapping is intentionally DB-only).
|
||||||
|
- Monitoring → Operations is reserved for `OperationRun`-tracked operations.
|
||||||
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
- Scheduled/queued operations MUST use locks + idempotency (no duplicates).
|
||||||
- Long-running operations MUST create run records with status, counts, and stable error codes.
|
|
||||||
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
- Graph throttling and transient failures MUST be handled with backoff + jitter (e.g., 429/503).
|
||||||
- Failures MUST be visible and actionable (no silent best-effort).
|
|
||||||
|
|
||||||
### Data Minimization & Safe Logging
|
### Data Minimization & Safe Logging
|
||||||
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
- Inventory MUST store only metadata + whitelisted `meta_jsonb`.
|
||||||
@ -83,4 +96,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.1.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-07
|
**Version**: 1.2.1 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-17
|
||||||
|
|||||||
@ -36,7 +36,8 @@ ## Constitution Check
|
|||||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||||
- Automation: queued/scheduled ops are locked, idempotent, observable; handle 429/503 with backoff+jitter
|
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log
|
||||||
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
@ -77,8 +77,10 @@ ### Edge Cases
|
|||||||
|
|
||||||
## Requirements *(mandatory)*
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls or any write/change behavior,
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
ACTION REQUIRED: The content in this section represents placeholders.
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
|||||||
@ -9,6 +9,9 @@ # Tasks: [FEATURE NAME]
|
|||||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
|
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
|
||||||
|
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
|
||||||
|
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
|
||||||
|
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
|
||||||
|
|
||||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
|||||||
@ -830,3 +830,10 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3 (054-unify-runs-suitewide-session-1768601416)
|
||||||
|
- PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe) (054-unify-runs-suitewide-session-1768601416)
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 054-unify-runs-suitewide-session-1768601416: Added PHP 8.4.15 (Laravel 12) + Filament v4, Livewire v3
|
||||||
|
|||||||
@ -669,3 +669,11 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||||
|
- 054-unify-runs-suitewide: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
|
- 054-unify-runs-suitewide: Added PHP 8.4 + Filament v4, Laravel v12, Livewire v3
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- PostgreSQL (`operation_runs` table + JSONB) (054-unify-runs-suitewide)
|
||||||
|
|||||||
@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
|
||||||
|
{--tenant=* : Limit to tenant_id/external_id}
|
||||||
|
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||||
|
{--dry-run : Do not write changes}';
|
||||||
|
|
||||||
|
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
||||||
|
|
||||||
|
public function handle(OperationRunService $operationRunService, BulkOperationService $bulkOperationService): int
|
||||||
|
{
|
||||||
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$query = OperationRun::query()
|
||||||
|
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||||
|
->whereIn('status', ['queued', 'running']);
|
||||||
|
|
||||||
|
if ($olderThanMinutes > 0) {
|
||||||
|
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tenantIdentifiers !== []) {
|
||||||
|
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
|
||||||
|
|
||||||
|
if ($tenantIds === []) {
|
||||||
|
$this->info('No tenants matched the provided identifiers.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereIn('tenant_id', $tenantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($query->cursor() as $operationRun) {
|
||||||
|
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
|
||||||
|
|
||||||
|
if (! is_numeric($backupScheduleRunId)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheduleRun = BackupScheduleRun::query()
|
||||||
|
->whereKey((int) $backupScheduleRunId)
|
||||||
|
->where('tenant_id', $operationRun->tenant_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $scheduleRun) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'RUN_NOT_FOUND',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason('Backup schedule run not found.'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRunService->updateRun($operationRun, 'running', 'pending');
|
||||||
|
|
||||||
|
if ($scheduleRun->started_at) {
|
||||||
|
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = match ($scheduleRun->status) {
|
||||||
|
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||||
|
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||||
|
BackupScheduleRun::STATUS_SKIPPED,
|
||||||
|
BackupScheduleRun::STATUS_CANCELED => 'cancelled',
|
||||||
|
default => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
||||||
|
$syncFailures = $summary['sync_failures'] ?? [];
|
||||||
|
|
||||||
|
$summaryCounts = [
|
||||||
|
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
||||||
|
'backup_set_id' => $scheduleRun->backup_set_id ? (int) $scheduleRun->backup_set_id : null,
|
||||||
|
'policies_total' => (int) ($summary['policies_total'] ?? 0),
|
||||||
|
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
|
||||||
|
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
||||||
|
$failures[] = [
|
||||||
|
'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($syncFailures)) {
|
||||||
|
foreach ($syncFailures as $failure) {
|
||||||
|
if (! is_array($failure)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||||
|
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||||
|
$errors = $failure['errors'] ?? null;
|
||||||
|
|
||||||
|
$firstErrorMessage = null;
|
||||||
|
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||||
|
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $status !== null
|
||||||
|
? "{$policyType}: Graph returned {$status}"
|
||||||
|
: "{$policyType}: Graph request failed";
|
||||||
|
|
||||||
|
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||||
|
$message .= ' - '.trim($firstErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures[] = [
|
||||||
|
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
|
||||||
|
$operationRun->forceFill([
|
||||||
|
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
|
||||||
|
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$reconciled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Reconciled %d run(s), skipped %d, failed %d.',
|
||||||
|
$reconciled,
|
||||||
|
$skipped,
|
||||||
|
$failed,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->comment('Dry-run: no changes written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tenantIdentifiers
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||||
|
{
|
||||||
|
$tenantIds = [];
|
||||||
|
|
||||||
|
foreach ($tenantIdentifiers as $identifier) {
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->forTenant($identifier)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($tenant) {
|
||||||
|
$tenantIds[] = (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tenantIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,20 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Filament\Pages;
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Drift\DriftRunSelector;
|
use App\Services\Drift\DriftRunSelector;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\RunIdempotency;
|
use App\Support\RunIdempotency;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -43,6 +46,8 @@ class DriftLanding extends Page
|
|||||||
|
|
||||||
public ?string $currentFinishedAt = null;
|
public ?string $currentFinishedAt = null;
|
||||||
|
|
||||||
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $bulkOperationRunId = null;
|
public ?int $bulkOperationRunId = null;
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
@ -98,6 +103,19 @@ public function mount(): void
|
|||||||
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
||||||
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||||
|
|
||||||
|
$existingOperationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'drift.generate')
|
||||||
|
->where('context->scope_key', $scopeKey)
|
||||||
|
->where('context->baseline_run_id', (int) $baseline->getKey())
|
||||||
|
->where('context->current_run_id', (int) $current->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingOperationRun instanceof OperationRun) {
|
||||||
|
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
$idempotencyKey = RunIdempotency::buildKey(
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
operationType: 'drift.generate',
|
operationType: 'drift.generate',
|
||||||
@ -184,6 +202,41 @@ public function mount(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'drift.generate',
|
||||||
|
inputs: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRunId = (int) $opRun->getKey();
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
$this->state = 'generating'; // Reflect generating state in UI if idempotency hit
|
||||||
|
// Optionally, we could find the related BulkOpRun to link, but the UI might just need state.
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Drift generation already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
$bulkOperationService = app(BulkOperationService::class);
|
$bulkOperationService = app(BulkOperationService::class);
|
||||||
$run = $bulkOperationService->createRun(
|
$run = $bulkOperationService->createRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -203,6 +256,9 @@ public function mount(): void
|
|||||||
$this->state = 'generating';
|
$this->state = 'generating';
|
||||||
$this->bulkOperationRunId = (int) $run->getKey();
|
$this->bulkOperationRunId = (int) $run->getKey();
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $baseline, $current, $scopeKey, $run, $opRun): void {
|
||||||
GenerateDriftFindingsJob::dispatch(
|
GenerateDriftFindingsJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
@ -210,21 +266,21 @@ public function mount(): void
|
|||||||
currentRunId: (int) $current->getKey(),
|
currentRunId: (int) $current->getKey(),
|
||||||
scopeKey: $scopeKey,
|
scopeKey: $scopeKey,
|
||||||
bulkOperationRunId: (int) $run->getKey(),
|
bulkOperationRunId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
Notification::make()
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
->title('Drift generation queued')
|
||||||
'run_type' => 'bulk_operation',
|
->body('Drift generation has been queued. Monitor progress in Monitoring → Operations.')
|
||||||
'run_id' => (int) $run->getKey(),
|
->success()
|
||||||
'status' => 'queued',
|
->actions([
|
||||||
'counts' => [
|
Action::make('view_run')
|
||||||
'total' => (int) $run->total_items,
|
->label('View run')
|
||||||
'processed' => (int) $run->processed_items,
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
'succeeded' => (int) $run->succeeded,
|
])
|
||||||
'failed' => (int) $run->failed,
|
->sendToDatabase($user)
|
||||||
'skipped' => (int) $run->skipped,
|
->send();
|
||||||
],
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFindingsUrl(): string
|
public function getFindingsUrl(): string
|
||||||
@ -250,12 +306,12 @@ public function getCurrentRunUrl(): ?string
|
|||||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getBulkRunUrl(): ?string
|
public function getOperationRunUrl(): ?string
|
||||||
{
|
{
|
||||||
if (! is_int($this->bulkOperationRunId)) {
|
if (! is_int($this->operationRunId)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return BulkOperationRunResource::getUrl('view', ['record' => $this->bulkOperationRunId], tenant: Tenant::current());
|
return OperationRunLinks::view($this->operationRunId, Tenant::current());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\Action as HintAction;
|
use Filament\Actions\Action as HintAction;
|
||||||
@ -150,17 +152,52 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
$computed = $inventorySyncService->normalizeAndHashSelection($selectionPayload);
|
||||||
|
|
||||||
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'inventory.sync',
|
||||||
|
inputs: $computed['selection'],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
// If run is already active (was recently created or re-used), and we want to enforce re-use:
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
// Just notify and exit (Idempotency)
|
||||||
|
Notification::make()
|
||||||
|
->title('Inventory sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
|
// Legacy checks (kept for safety if parallel usage needs it, though OpRun handles idempotency now)
|
||||||
$existing = InventorySyncRun::query()
|
$existing = InventorySyncRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('selection_hash', $computed['selection_hash'])
|
->where('selection_hash', $computed['selection_hash'])
|
||||||
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
->whereIn('status', [InventorySyncRun::STATUS_PENDING, InventorySyncRun::STATUS_RUNNING])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// If legacy thinks it's running but OpRun didn't catch it (unlikely with shared hash logic), fail safe.
|
||||||
if ($existing instanceof InventorySyncRun) {
|
if ($existing instanceof InventorySyncRun) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Sync already running')
|
->title('Inventory sync already active')
|
||||||
->body('An inventory sync is already running for this tenant. Check the progress widget for status.')
|
->body('A matching inventory sync run is already pending or running.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -201,19 +238,27 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Inventory sync started')
|
->title('Inventory sync started')
|
||||||
->body('Sync dispatched. Check the bottom-right progress widget for status.')
|
->body('Sync dispatched. Check the progress widget or Monitoring.')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->iconColor('warning')
|
->iconColor('warning')
|
||||||
->success()
|
->success()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $bulkRun, $opRun): void {
|
||||||
RunInventorySyncJob::dispatch(
|
RunInventorySyncJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
|
inventorySyncRunId: (int) $run->id,
|
||||||
bulkRunId: (int) $bulkRun->getKey(),
|
bulkRunId: (int) $bulkRun->getKey(),
|
||||||
inventorySyncRunId: (int) $run->getKey(),
|
operationRun: $opRun
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
125
app/Filament/Pages/Monitoring/Operations.php
Normal file
125
app/Filament/Pages/Monitoring/Operations.php
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Monitoring;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class Operations extends Page implements HasForms, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static bool $isDiscovered = false;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Operations';
|
||||||
|
|
||||||
|
// Must be non-static
|
||||||
|
protected string $view = 'filament.pages.monitoring.operations';
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
OperationRun::query()
|
||||||
|
->where('tenant_id', Filament::getTenant()->id)
|
||||||
|
->latest('created_at')
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('type')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'secondary' => 'queued',
|
||||||
|
'warning' => 'running',
|
||||||
|
'success' => 'completed',
|
||||||
|
]),
|
||||||
|
|
||||||
|
TextColumn::make('outcome')
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'gray' => 'pending',
|
||||||
|
'success' => 'succeeded',
|
||||||
|
'warning' => 'partially_succeeded',
|
||||||
|
'danger' => 'failed',
|
||||||
|
'secondary' => 'cancelled',
|
||||||
|
]),
|
||||||
|
|
||||||
|
TextColumn::make('initiator_name')
|
||||||
|
->label('Initiator')
|
||||||
|
->searchable(),
|
||||||
|
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->label('Started'),
|
||||||
|
|
||||||
|
TextColumn::make('duration')
|
||||||
|
->getStateUsing(function (OperationRun $record) {
|
||||||
|
if ($record->started_at && $record->completed_at) {
|
||||||
|
return $record->completed_at->diffForHumans($record->started_at, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '-';
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('outcome')
|
||||||
|
->options([
|
||||||
|
'succeeded' => 'Succeeded',
|
||||||
|
'partially_succeeded' => 'Partially Succeeded',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
'cancelled' => 'Cancelled',
|
||||||
|
'pending' => 'Pending',
|
||||||
|
]),
|
||||||
|
|
||||||
|
SelectFilter::make('type')
|
||||||
|
->options(
|
||||||
|
fn () => OperationRun::where('tenant_id', Filament::getTenant()->id)
|
||||||
|
->distinct()
|
||||||
|
->pluck('type', 'type')
|
||||||
|
->toArray()
|
||||||
|
),
|
||||||
|
|
||||||
|
Filter::make('created_at')
|
||||||
|
->form([
|
||||||
|
DatePicker::make('created_from'),
|
||||||
|
DatePicker::make('created_until'),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
return $query
|
||||||
|
->when(
|
||||||
|
$data['created_from'],
|
||||||
|
fn (Builder $query, $date) => $query->whereDate('created_at', '>=', $date),
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$data['created_until'],
|
||||||
|
fn (Builder $query, $date) => $query->whereDate('created_at', '<=', $date),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
// View action handled by opening a modal or side-peek
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@
|
|||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\TenantRole;
|
use App\Support\TenantRole;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
@ -30,7 +32,6 @@
|
|||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
@ -295,6 +296,32 @@ public static function table(Table $table): Table
|
|||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.run_now',
|
||||||
|
inputs: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $userModel
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Run already queued')
|
||||||
|
->body('This schedule already has a queued or running backup.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
$run = null;
|
$run = null;
|
||||||
|
|
||||||
@ -319,11 +346,38 @@ public static function table(Table $table): Table
|
|||||||
->title('Run already queued')
|
->title('Run already queued')
|
||||||
->body('Please wait a moment and try again.')
|
->body('Please wait a moment and try again.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'SCHEDULE_CONFLICT',
|
||||||
|
'message' => 'Unable to queue a unique backup schedule run.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
@ -355,17 +409,19 @@ public static function table(Table $table): Table
|
|||||||
->id;
|
->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
|
||||||
|
});
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Run dispatched')
|
->title('Run dispatched')
|
||||||
->body('The backup run has been queued.')
|
->body('The backup run has been queued.')
|
||||||
->success();
|
->success()
|
||||||
|
->actions([
|
||||||
if ($userModel instanceof User) {
|
Action::make('view_run')
|
||||||
$userModel->notifyNow($notification->toDatabase());
|
->label('View run')
|
||||||
DatabaseNotificationsSent::dispatch($userModel);
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
}
|
]);
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
@ -382,6 +438,32 @@ public static function table(Table $table): Table
|
|||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.retry',
|
||||||
|
inputs: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $userModel
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Retry already queued')
|
||||||
|
->body('This schedule already has a queued or running retry.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
$run = null;
|
$run = null;
|
||||||
|
|
||||||
@ -406,11 +488,38 @@ public static function table(Table $table): Table
|
|||||||
->title('Retry already queued')
|
->title('Retry already queued')
|
||||||
->body('Please wait a moment and try again.')
|
->body('Please wait a moment and try again.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'SCHEDULE_CONFLICT',
|
||||||
|
'message' => 'Unable to queue a unique backup schedule retry run.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
@ -442,17 +551,19 @@ public static function table(Table $table): Table
|
|||||||
->id;
|
->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId));
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
|
||||||
|
});
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Retry dispatched')
|
->title('Retry dispatched')
|
||||||
->body('A new backup run has been queued.')
|
->body('A new backup run has been queued.')
|
||||||
->success();
|
->success()
|
||||||
|
->actions([
|
||||||
if ($userModel instanceof User) {
|
Action::make('view_run')
|
||||||
$userModel->notifyNow($notification->toDatabase());
|
->label('View run')
|
||||||
DatabaseNotificationsSent::dispatch($userModel);
|
->url(OperationRunLinks::view($operationRun, $tenant)),
|
||||||
}
|
]);
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
}),
|
}),
|
||||||
@ -479,6 +590,8 @@ public static function table(Table $table): Table
|
|||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$user = $userId ? User::query()->find($userId) : null;
|
$user = $userId ? User::query()->find($userId) : null;
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
$bulkRun = null;
|
$bulkRun = null;
|
||||||
if ($user) {
|
if ($user) {
|
||||||
@ -496,6 +609,19 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.run_now',
|
||||||
|
inputs: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
$run = null;
|
$run = null;
|
||||||
|
|
||||||
@ -516,11 +642,33 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'SCHEDULE_CONFLICT',
|
||||||
|
'message' => 'Unable to queue a unique backup schedule run.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
@ -538,7 +686,9 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
||||||
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
@ -552,8 +702,11 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($user instanceof User) {
|
if ($user instanceof User) {
|
||||||
$user->notifyNow($notification->toDatabase());
|
$notification->actions([
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
Action::make('view_runs')
|
||||||
|
->label('View in Operations')
|
||||||
|
->url(OperationRunLinks::index($tenant)),
|
||||||
|
])->sendToDatabase($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
@ -573,6 +726,8 @@ public static function table(Table $table): Table
|
|||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$user = $userId ? User::query()->find($userId) : null;
|
$user = $userId ? User::query()->find($userId) : null;
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
$bulkRun = null;
|
$bulkRun = null;
|
||||||
if ($user) {
|
if ($user) {
|
||||||
@ -590,6 +745,19 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
/** @var BackupSchedule $record */
|
/** @var BackupSchedule $record */
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.retry',
|
||||||
|
inputs: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $operationRun->wasRecentlyCreated && in_array($operationRun->status, ['queued', 'running'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||||
$run = null;
|
$run = null;
|
||||||
|
|
||||||
@ -610,11 +778,33 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $run instanceof BackupScheduleRun) {
|
if (! $run instanceof BackupScheduleRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'SCHEDULE_CONFLICT',
|
||||||
|
'message' => 'Unable to queue a unique backup schedule retry run.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$createdRunIds[] = (int) $run->id;
|
$createdRunIds[] = (int) $run->id;
|
||||||
|
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $record->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_dispatched_manual',
|
action: 'backup_schedule.run_dispatched_manual',
|
||||||
@ -632,7 +822,9 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id));
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
||||||
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
@ -646,8 +838,11 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($user instanceof User) {
|
if ($user instanceof User) {
|
||||||
$user->notifyNow($notification->toDatabase());
|
$notification->actions([
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
Action::make('view_runs')
|
||||||
|
->label('View in Operations')
|
||||||
|
->url(OperationRunLinks::index($tenant)),
|
||||||
|
])->sendToDatabase($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
$notification->send();
|
$notification->send();
|
||||||
|
|||||||
@ -3,8 +3,12 @@
|
|||||||
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
namespace App\Filament\Resources\BackupSetResource\RelationManagers;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
@ -18,10 +22,30 @@ class BackupItemsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'items';
|
protected static string $relationship = 'items';
|
||||||
|
|
||||||
|
public ?int $pollUntil = null;
|
||||||
|
|
||||||
|
protected $listeners = [
|
||||||
|
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function closeAddPoliciesModal(): void
|
||||||
|
{
|
||||||
|
$this->unmountAction();
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shouldPollTable(): bool
|
||||||
|
{
|
||||||
|
return $this->pollUntil !== null && now()->getTimestamp() < $this->pollUntil;
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
|
||||||
|
->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('policy.display_name')
|
Tables\Columns\TextColumn::make('policy.display_name')
|
||||||
->label('Item')
|
->label('Item')
|
||||||
@ -128,30 +152,77 @@ public function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (BackupItem $record, AuditLogger $auditLogger) {
|
->action(function (BackupItem $record): void {
|
||||||
$record->delete();
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
if ($record->backupSet) {
|
$user = auth()->user();
|
||||||
$record->backupSet->update([
|
if (! $user instanceof User) {
|
||||||
'item_count' => $record->backupSet->items()->count(),
|
abort(403);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record->tenant,
|
if (! $user->canSyncTenant($tenant)) {
|
||||||
action: 'backup.item_removed',
|
abort(403);
|
||||||
resourceType: 'backup_set',
|
}
|
||||||
resourceId: (string) $record->backup_set_id,
|
|
||||||
status: 'success',
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
context: ['metadata' => ['policy_id' => $record->policy_id]]
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = [(int) $record->getKey()];
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal already queued')
|
||||||
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy removed from backup')
|
->title('Removal queued')
|
||||||
|
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')
|
||||||
->success()
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
|
||||||
}),
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
@ -162,42 +233,93 @@ public function table(Table $table): Table
|
|||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger) {
|
->deselectRecordsAfterCompletion()
|
||||||
|
->action(function (Collection $records): void {
|
||||||
if ($records->isEmpty()) {
|
if ($records->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupSet = $this->getOwnerRecord();
|
$backupSet = $this->getOwnerRecord();
|
||||||
|
|
||||||
$records->each(fn (BackupItem $record) => $record->delete());
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
$backupSet->update([
|
abort(403);
|
||||||
'item_count' => $backupSet->items()->count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant = $records->first()?->tenant;
|
|
||||||
|
|
||||||
if ($tenant) {
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'backup.items_removed',
|
|
||||||
resourceType: 'backup_set',
|
|
||||||
resourceId: (string) $backupSet->id,
|
|
||||||
status: 'success',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'removed_count' => $records->count(),
|
|
||||||
'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(),
|
|
||||||
'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(),
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
|
||||||
|
if (! $user->canSyncTenant($tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $tenant->getKey() !== (int) $backupSet->tenant_id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupItemIds = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($backupItemIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.remove_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => $backupItemIds,
|
||||||
|
],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policies removed from backup')
|
->title('Removal already queued')
|
||||||
->success()
|
->body('A matching remove operation is already queued or running.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($backupSet, $backupItemIds, $user, $opRun): void {
|
||||||
|
RemovePoliciesFromBackupSetJob::dispatch(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: $backupItemIds,
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Removal queued')
|
||||||
|
->body('A background job has been queued. You can monitor progress in the run details or progress widget.')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
$this->pollUntil = now()->addSeconds(20)->getTimestamp();
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\BulkOperationRunResource\Pages;
|
use App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
@ -24,6 +25,8 @@ class BulkOperationRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $model = BulkOperationRun::class;
|
protected static ?string $model = BulkOperationRun::class;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
@ -39,6 +42,18 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Legacy run view')
|
||||||
|
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('canonical_view')
|
||||||
|
->label('Canonical view')
|
||||||
|
->state('View in Operations')
|
||||||
|
->url(fn (BulkOperationRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||||
|
->badge()
|
||||||
|
->color('primary'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Run')
|
Section::make('Run')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('user.name')
|
TextEntry::make('user.name')
|
||||||
|
|||||||
@ -8,9 +8,11 @@
|
|||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
class ListEntraGroups extends ListRecords
|
||||||
@ -72,6 +74,32 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'directory_groups.sync',
|
||||||
|
inputs: ['selection_key' => $selectionKey],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Group sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
$existing = EntraGroupSyncRun::query()
|
$existing = EntraGroupSyncRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('selection_key', $selectionKey)
|
->where('selection_key', $selectionKey)
|
||||||
@ -80,14 +108,17 @@ protected function getHeaderActions(): array
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing instanceof EntraGroupSyncRun) {
|
if ($existing instanceof EntraGroupSyncRun) {
|
||||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
Notification::make()
|
||||||
|
->title('Group sync already active')
|
||||||
$user->notify(new RunStatusChangedNotification([
|
->body('This operation is already queued or running.')
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
->warning()
|
||||||
'run_type' => 'directory_groups',
|
->actions([
|
||||||
'run_id' => (int) $existing->getKey(),
|
Action::make('view_run')
|
||||||
'status' => $normalizedStatus,
|
->label('View Run')
|
||||||
]));
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -105,14 +136,20 @@ protected function getHeaderActions(): array
|
|||||||
selectionKey: $selectionKey,
|
selectionKey: $selectionKey,
|
||||||
slotKey: null,
|
slotKey: null,
|
||||||
runId: (int) $run->getKey(),
|
runId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun
|
||||||
));
|
));
|
||||||
|
|
||||||
$user->notify(new RunStatusChangedNotification([
|
Notification::make()
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
->title('Group sync started')
|
||||||
'run_type' => 'directory_groups',
|
->body('Sync dispatched.')
|
||||||
'run_id' => (int) $run->getKey(),
|
->success()
|
||||||
'status' => 'queued',
|
->actions([
|
||||||
]));
|
Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
@ -23,6 +24,8 @@ class EntraGroupSyncRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $model = EntraGroupSyncRun::class;
|
protected static ?string $model = EntraGroupSyncRun::class;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||||
@ -38,6 +41,18 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Legacy run view')
|
||||||
|
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('canonical_view')
|
||||||
|
->label('Canonical view')
|
||||||
|
->state('View in Operations')
|
||||||
|
->url(fn (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||||
|
->badge()
|
||||||
|
->color('primary'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Sync Run')
|
Section::make('Sync Run')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('initiator.name')
|
TextEntry::make('initiator.name')
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
@ -21,6 +22,8 @@ class InventorySyncRunResource extends Resource
|
|||||||
{
|
{
|
||||||
protected static ?string $model = InventorySyncRun::class;
|
protected static ?string $model = InventorySyncRun::class;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
@ -34,6 +37,18 @@ public static function infolist(Schema $schema): Schema
|
|||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Section::make('Legacy run view')
|
||||||
|
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('canonical_view')
|
||||||
|
->label('Canonical view')
|
||||||
|
->state('View in Operations')
|
||||||
|
->url(fn (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||||
|
->badge()
|
||||||
|
->color('primary'),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
Section::make('Sync Run')
|
Section::make('Sync Run')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('user.name')
|
TextEntry::make('user.name')
|
||||||
|
|||||||
245
app/Filament/Resources/OperationRunResource.php
Normal file
245
app/Filament/Resources/OperationRunResource.php
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class OperationRunResource extends Resource
|
||||||
|
{
|
||||||
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
|
protected static ?string $model = OperationRun::class;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'operations';
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Operations';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Run')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('type')->badge(),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
|
||||||
|
TextEntry::make('outcome')
|
||||||
|
->badge()
|
||||||
|
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
||||||
|
TextEntry::make('initiator_name')->label('Initiator'),
|
||||||
|
TextEntry::make('created_at')->dateTime(),
|
||||||
|
TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Counts')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('summary_counts')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Failures')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('failure_summary')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (OperationRun $record): array => $record->context ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->modifyQueryUsing(function (Builder $query): Builder {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('status')
|
||||||
|
->badge()
|
||||||
|
->color(fn (OperationRun $record): string => static::statusColor($record->status)),
|
||||||
|
Tables\Columns\TextColumn::make('type')
|
||||||
|
->label('Operation')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('initiator_name')
|
||||||
|
->label('Initiator')
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('Started')
|
||||||
|
->since()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('duration')
|
||||||
|
->getStateUsing(function (OperationRun $record): string {
|
||||||
|
if ($record->started_at && $record->completed_at) {
|
||||||
|
return $record->completed_at->diffForHumans($record->started_at, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '—';
|
||||||
|
}),
|
||||||
|
Tables\Columns\TextColumn::make('outcome')
|
||||||
|
->badge()
|
||||||
|
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('type')
|
||||||
|
->options(function (): array {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->select('type')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('type')
|
||||||
|
->pluck('type', 'type')
|
||||||
|
->all();
|
||||||
|
}),
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options([
|
||||||
|
OperationRunStatus::Queued->value => 'Queued',
|
||||||
|
OperationRunStatus::Running->value => 'Running',
|
||||||
|
OperationRunStatus::Completed->value => 'Completed',
|
||||||
|
]),
|
||||||
|
Tables\Filters\SelectFilter::make('outcome')
|
||||||
|
->options(OperationRunOutcome::uiLabels(includeReserved: false)),
|
||||||
|
Tables\Filters\SelectFilter::make('initiator_name')
|
||||||
|
->label('Initiator')
|
||||||
|
->options(function (): array {
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
if (! $tenantId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return OperationRun::query()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereNotNull('initiator_name')
|
||||||
|
->select('initiator_name')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('initiator_name')
|
||||||
|
->pluck('initiator_name', 'initiator_name')
|
||||||
|
->all();
|
||||||
|
})
|
||||||
|
->searchable(),
|
||||||
|
Tables\Filters\Filter::make('created_at')
|
||||||
|
->label('Created')
|
||||||
|
->form([
|
||||||
|
DatePicker::make('created_from')
|
||||||
|
->label('From'),
|
||||||
|
DatePicker::make('created_until')
|
||||||
|
->label('Until'),
|
||||||
|
])
|
||||||
|
->default(fn (): array => [
|
||||||
|
'created_from' => now()->subDays(30),
|
||||||
|
'created_until' => now(),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$from = $data['created_from'] ?? null;
|
||||||
|
if ($from) {
|
||||||
|
$query->whereDate('created_at', '>=', $from);
|
||||||
|
}
|
||||||
|
|
||||||
|
$until = $data['created_until'] ?? null;
|
||||||
|
if ($until) {
|
||||||
|
$query->whereDate('created_at', '<=', $until);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('user')
|
||||||
|
->latest('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListOperationRuns::route('/'),
|
||||||
|
'view' => Pages\ViewOperationRun::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function statusColor(?string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'queued' => 'gray',
|
||||||
|
'running' => 'info',
|
||||||
|
'completed' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function outcomeColor(?string $outcome): string
|
||||||
|
{
|
||||||
|
return match ($outcome) {
|
||||||
|
'succeeded' => 'success',
|
||||||
|
'partially_succeeded' => 'warning',
|
||||||
|
'failed' => 'danger',
|
||||||
|
'cancelled' => 'gray',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListOperationRuns extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = OperationRunResource::class;
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\OperationRunResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ViewOperationRun extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = OperationRunResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $this->getRecord();
|
||||||
|
|
||||||
|
$related = OperationRunLinks::related($run, $tenant);
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
foreach ($related as $label => $url) {
|
||||||
|
$actions[] = Actions\Action::make(Str::slug($label, '_'))
|
||||||
|
->label($label)
|
||||||
|
->url($url)
|
||||||
|
->openUrlInNewTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($actions)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Actions\ActionGroup::make($actions)
|
||||||
|
->label('Open')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,15 @@
|
|||||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
use App\Jobs\BulkPolicyExportJob;
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
use App\Jobs\BulkPolicySyncJob;
|
|
||||||
use App\Jobs\BulkPolicyUnignoreJob;
|
use App\Jobs\BulkPolicyUnignoreJob;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -371,15 +374,77 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
->visible(function (Policy $record): bool {
|
||||||
|
if ($record->ignored_at !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $user->canSyncTenant($tenant);
|
||||||
|
})
|
||||||
->action(function (Policy $record) {
|
->action(function (Policy $record) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'sync', [$record->id], 1);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
BulkPolicySyncJob::dispatchSync($run->id);
|
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync_one',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'one',
|
||||||
|
'policy_id' => (int) $record->getKey(),
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
policyIds: [(int) $record->getKey()],
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy sync queued')
|
||||||
|
->body('The sync has been queued. You can monitor progress in Monitoring → Operations.')
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
@ -501,6 +566,18 @@ public static function table(Table $table): Table
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
$visibilityFilterState = $livewire->getTableFilterState('visibility') ?? [];
|
||||||
$value = $visibilityFilterState['value'] ?? null;
|
$value = $visibilityFilterState['value'] ?? null;
|
||||||
|
|
||||||
@ -510,26 +587,67 @@ public static function table(Table $table): Table
|
|||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'sync', $ids, $count);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $records
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'subset',
|
||||||
|
'policy_ids' => $ids,
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk sync started')
|
->title('Policy sync already active')
|
||||||
->body("Syncing {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
->body('This operation is already queued or running.')
|
||||||
->icon('heroicon-o-arrow-path')
|
->warning()
|
||||||
->iconColor('warning')
|
->actions([
|
||||||
->info()
|
Actions\Action::make('view_run')
|
||||||
->duration(8000)
|
->label('View run')
|
||||||
->sendToDatabase($user)
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
BulkPolicySyncJob::dispatch($run->id);
|
return;
|
||||||
} else {
|
|
||||||
BulkPolicySyncJob::dispatchSync($run->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
policyIds: $ids,
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy sync queued')
|
||||||
|
->body("The sync has been queued for {$count} policies. You can monitor progress in Monitoring → Operations.")
|
||||||
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,11 @@
|
|||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
@ -21,53 +24,82 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function () {
|
->visible(function (): bool {
|
||||||
try {
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
/** @var PolicySyncService $service */
|
return $user->canSyncTenant($tenant);
|
||||||
$service = app(PolicySyncService::class);
|
})
|
||||||
|
->action(function () {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
$result = $service->syncPoliciesWithReport($tenant);
|
if (! $user instanceof User) {
|
||||||
$syncedCount = count($result['synced'] ?? []);
|
abort(403);
|
||||||
$failureCount = count($result['failures'] ?? []);
|
|
||||||
|
|
||||||
$body = $syncedCount.' policies synced';
|
|
||||||
|
|
||||||
if ($failureCount > 0) {
|
|
||||||
$first = $result['failures'][0] ?? [];
|
|
||||||
$firstType = $first['policy_type'] ?? 'unknown';
|
|
||||||
$firstStatus = $first['status'] ?? null;
|
|
||||||
|
|
||||||
$firstErrorMessage = null;
|
|
||||||
$firstErrors = $first['errors'] ?? null;
|
|
||||||
if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) {
|
|
||||||
$firstErrorMessage = $firstErrors[0]['message'] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}";
|
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
||||||
|
abort(403);
|
||||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
|
||||||
$suffix .= ' - '.trim($firstErrorMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$body .= " ({$failureCount} failed; {$suffix})";
|
$requestedTypes = array_map(
|
||||||
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
|
config('tenantpilot.supported_policy_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
sort($requestedTypes);
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'all',
|
||||||
|
'types' => $requestedTypes,
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
||||||
|
SyncPoliciesJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
types: $requestedTypes,
|
||||||
|
operationRun: $opRun
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy sync completed')
|
->title('Policy sync queued')
|
||||||
->body($body)
|
->body('The sync has been queued. You can monitor progress in Monitoring → Operations.')
|
||||||
->success()
|
->success()
|
||||||
->sendToDatabase(auth()->user())
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Policy sync failed')
|
|
||||||
->body($e->getMessage())
|
|
||||||
->danger()
|
|
||||||
->sendToDatabase(auth()->user())
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,7 +149,7 @@ public static function form(Schema $schema): Schema
|
|||||||
'sourceGroupId' => $groupId,
|
'sourceGroupId' => $groupId,
|
||||||
]))
|
]))
|
||||||
)
|
)
|
||||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||||
->hintAction(
|
->hintAction(
|
||||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
@ -399,7 +399,7 @@ public static function getWizardSteps(): array
|
|||||||
'sourceGroupId' => $groupId,
|
'sourceGroupId' => $groupId,
|
||||||
]))
|
]))
|
||||||
)
|
)
|
||||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
->helperText(fn (): string => ($cacheNotice ? ($cacheNotice.' ') : '').'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||||
->hintAction(
|
->hintAction(
|
||||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||||
use App\Jobs\BulkTenantSyncJob;
|
use App\Jobs\BulkTenantSyncJob;
|
||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\EntraGroup;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
@ -17,6 +16,8 @@
|
|||||||
use App\Services\Intune\RbacOnboardingService;
|
use App\Services\Intune\RbacOnboardingService;
|
||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Intune\TenantConfigService;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\TenantRole;
|
use App\Support\TenantRole;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -40,6 +41,7 @@
|
|||||||
|
|
||||||
class TenantResource extends Resource
|
class TenantResource extends Resource
|
||||||
{
|
{
|
||||||
|
// ... [Properties Omitted for Brevity] ...
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
@ -50,6 +52,7 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
|
// ... [Schema Omitted - No Change] ...
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Forms\Components\TextInput::make('name')
|
Forms\Components\TextInput::make('name')
|
||||||
@ -91,6 +94,7 @@ public static function form(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
|
// ... [Query Omitted - No Change] ...
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
@ -194,7 +198,32 @@ public static function table(Table $table): Table
|
|||||||
return $user->canSyncTenant($record);
|
return $user->canSyncTenant($record);
|
||||||
})
|
})
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
SyncPoliciesJob::dispatch($record->getKey());
|
// Phase 3: Canonical Operation Run Start
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $record,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: ['scope' => 'full'],
|
||||||
|
initiator: auth()->user()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Policy sync already active')
|
||||||
|
->body('This operation is already queued or running.')
|
||||||
|
->warning()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $record)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch($record->getKey(), null, $opRun);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
@ -211,6 +240,11 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->iconColor('warning')
|
->iconColor('warning')
|
||||||
->success()
|
->success()
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View Run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $record)),
|
||||||
|
])
|
||||||
->sendToDatabase(auth()->user())
|
->sendToDatabase(auth()->user())
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
@ -421,7 +455,21 @@ public static function table(Table $table): Table
|
|||||||
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
|
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
|
||||||
|
|
||||||
foreach ($eligible as $tenant) {
|
foreach ($eligible as $tenant) {
|
||||||
SyncPoliciesJob::dispatch($tenant->getKey());
|
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
|
||||||
|
// Bulk operation across tenants is a higher level concept.
|
||||||
|
// Keeping it as is for now or migrating individually.
|
||||||
|
// If we want each tenant sync to show in its Monitoring, we should create opRun for each.
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -454,6 +502,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
|
// ... [Infolist Omitted - No Change] ...
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\TextEntry::make('name'),
|
Infolists\Components\TextEntry::make('name'),
|
||||||
@ -542,6 +591,7 @@ public static function getPages(): array
|
|||||||
|
|
||||||
public static function rbacAction(): Actions\Action
|
public static function rbacAction(): Actions\Action
|
||||||
{
|
{
|
||||||
|
// ... [RBAC Action Omitted - No Change] ...
|
||||||
return Actions\Action::make('setup_rbac')
|
return Actions\Action::make('setup_rbac')
|
||||||
->label('Setup Intune RBAC')
|
->label('Setup Intune RBAC')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
@ -587,7 +637,9 @@ public static function rbacAction(): Actions\Action
|
|||||||
->placeholder('Search security groups')
|
->placeholder('Search security groups')
|
||||||
->visible(fn (Get $get) => $get('scope') === 'scope_group')
|
->visible(fn (Get $get) => $get('scope') === 'scope_group')
|
||||||
->required(fn (Get $get) => $get('scope') === 'scope_group')
|
->required(fn (Get $get) => $get('scope') === 'scope_group')
|
||||||
|
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
|
||||||
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
||||||
|
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
|
||||||
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
||||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
||||||
->noSearchResultsMessage('No security groups found')
|
->noSearchResultsMessage('No security groups found')
|
||||||
@ -614,7 +666,9 @@ public static function rbacAction(): Actions\Action
|
|||||||
->placeholder('Search security groups')
|
->placeholder('Search security groups')
|
||||||
->visible(fn (Get $get) => $get('group_mode') === 'existing')
|
->visible(fn (Get $get) => $get('group_mode') === 'existing')
|
||||||
->required(fn (Get $get) => $get('group_mode') === 'existing')
|
->required(fn (Get $get) => $get('group_mode') === 'existing')
|
||||||
|
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
|
||||||
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
|
||||||
|
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
|
||||||
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
|
||||||
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
|
||||||
->noSearchResultsMessage('No security groups found')
|
->noSearchResultsMessage('No security groups found')
|
||||||
@ -985,7 +1039,7 @@ public static function groupSearchHelper(?Tenant $tenant): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Uses cached directory groups only (no live Graph lookups). Run “Sync Groups” if results are empty.';
|
return static::delegatedToken($tenant) ? null : 'Login to search groups';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -997,14 +1051,43 @@ public static function groupSearchOptions(?Tenant $tenant, string $search): arra
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return EntraGroup::query()
|
$token = static::delegatedToken($tenant);
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('display_name', 'ilike', '%'.str_replace('%', '\\%', $search).'%')
|
if (! $token) {
|
||||||
->orderBy('display_name')
|
return [];
|
||||||
->limit(20)
|
}
|
||||||
->get(['entra_id', 'display_name'])
|
|
||||||
->mapWithKeys(fn (EntraGroup $group) => [
|
$filter = sprintf(
|
||||||
(string) $group->entra_id => EntraGroupLabelResolver::formatLabel($group->display_name, (string) $group->entra_id),
|
"securityEnabled eq true and startswith(displayName,'%s')",
|
||||||
|
static::escapeOdataValue($search)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = app(GraphClientInterface::class)->request(
|
||||||
|
'GET',
|
||||||
|
'groups',
|
||||||
|
[
|
||||||
|
'query' => [
|
||||||
|
'$select' => 'id,displayName',
|
||||||
|
'$top' => 20,
|
||||||
|
'$filter' => $filter,
|
||||||
|
],
|
||||||
|
] + $tenant->graphOptions() + [
|
||||||
|
'access_token' => $token,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($response->data['value'] ?? [])
|
||||||
|
->filter(fn (array $group) => filled($group['id'] ?? null))
|
||||||
|
->mapWithKeys(fn (array $group) => [
|
||||||
|
(string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']),
|
||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -1015,7 +1098,32 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s
|
|||||||
return $groupId;
|
return $groupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return app(EntraGroupLabelResolver::class)->resolveOne($tenant, $groupId);
|
$token = static::delegatedToken($tenant);
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return $groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = app(GraphClientInterface::class)->request(
|
||||||
|
'GET',
|
||||||
|
'groups/'.$groupId,
|
||||||
|
[] + $tenant->graphOptions() + [
|
||||||
|
'access_token' => $token,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return $groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return $groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EntraGroupLabelResolver::formatLabel(
|
||||||
|
$response->data['displayName'] ?? null,
|
||||||
|
$response->data['id'] ?? $groupId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function verifyTenant(
|
public static function verifyTenant(
|
||||||
|
|||||||
@ -3,15 +3,19 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\FoundationSnapshotService;
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Services\Intune\SnapshotValidator;
|
use App\Services\Intune\SnapshotValidator;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -28,13 +32,23 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $bulkRunId,
|
public int $bulkRunId,
|
||||||
public int $backupSetId,
|
public int $backupSetId,
|
||||||
public bool $includeAssignments,
|
public bool $includeAssignments,
|
||||||
public bool $includeScopeTags,
|
public bool $includeScopeTags,
|
||||||
public bool $includeFoundations,
|
public bool $includeFoundations,
|
||||||
) {}
|
?OperationRun $operationRun = null
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
BulkOperationService $bulkOperationService,
|
BulkOperationService $bulkOperationService,
|
||||||
@ -111,6 +125,12 @@ public function handle(
|
|||||||
if ($policyIds === []) {
|
if ($policyIds === []) {
|
||||||
$bulkOperationService->complete($run);
|
$bulkOperationService->complete($run);
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,6 +422,30 @@ public function handle(
|
|||||||
|
|
||||||
$bulkOperationService->complete($run);
|
$bulkOperationService->complete($run);
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opOutcome = match (true) {
|
||||||
|
$run->status === 'completed' => 'succeeded',
|
||||||
|
$run->status === 'completed_with_errors' => 'partially_succeeded',
|
||||||
|
$run->status === 'failed' => 'failed',
|
||||||
|
default => 'failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$opOutcome,
|
||||||
|
[
|
||||||
|
'policies_added' => $backupSetItemMutations,
|
||||||
|
'foundations_added' => $foundationMutations,
|
||||||
|
'failures' => count($newBackupFailures),
|
||||||
|
],
|
||||||
|
$newBackupFailures
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $run->user) {
|
if (! $run->user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -432,7 +476,7 @@ public function handle(
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($partial) {
|
if ($partial) {
|
||||||
@ -460,6 +504,7 @@ public function handle(
|
|||||||
reason: $throwable->getMessage(),
|
reason: $throwable->getMessage(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TrackOperationRun will catch this throw
|
||||||
throw $throwable;
|
throw $throwable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -523,6 +568,18 @@ private function markRunFailed(
|
|||||||
$run->update(['status' => 'failed']);
|
$run->update(['status' => 'failed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
['failure_reason' => $reason],
|
||||||
|
[['code' => $reasonCode, 'message' => $reason]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->notifyRunFailed($run, $tenant, $reason);
|
$this->notifyRunFailed($run, $tenant, $reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -540,7 +597,7 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string
|
|||||||
$notification->actions([
|
$notification->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Directory\EntraGroupSyncService;
|
use App\Services\Directory\EntraGroupSyncService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -18,12 +21,22 @@ class EntraGroupSyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantId,
|
public int $tenantId,
|
||||||
public string $selectionKey,
|
public string $selectionKey,
|
||||||
public ?string $slotKey = null,
|
public ?string $slotKey = null,
|
||||||
public ?int $runId = null,
|
public ?int $runId = null,
|
||||||
) {}
|
?OperationRun $operationRun = null
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
|
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
|
||||||
{
|
{
|
||||||
@ -35,6 +48,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
|||||||
$run = $this->resolveRun($tenant);
|
$run = $this->resolveRun($tenant);
|
||||||
|
|
||||||
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
|
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
|
||||||
|
// Already ran?
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +95,31 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
|||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
'finished_at' => CarbonImmutable::now('UTC'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Update OperationRun with stats
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$opOutcome = match ($terminalStatus) {
|
||||||
|
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
|
||||||
|
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||||
|
EntraGroupSyncRun::STATUS_FAILED => 'failed',
|
||||||
|
default => 'failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$opOutcome,
|
||||||
|
[
|
||||||
|
'fetched' => $result['items_observed_count'],
|
||||||
|
'upserted' => $result['items_upserted_count'],
|
||||||
|
'errors' => $result['error_count'],
|
||||||
|
],
|
||||||
|
$result['error_summary'] ? [['code' => $result['error_code'] ?? 'ERR', 'message' => json_encode($result['error_summary'])]] : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
||||||
|
|||||||
@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -21,6 +26,8 @@ class GenerateDriftFindingsJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantId,
|
public int $tenantId,
|
||||||
public int $userId,
|
public int $userId,
|
||||||
@ -28,7 +35,15 @@ public function __construct(
|
|||||||
public int $currentRunId,
|
public int $currentRunId,
|
||||||
public string $scopeKey,
|
public string $scopeKey,
|
||||||
public int $bulkOperationRunId,
|
public int $bulkOperationRunId,
|
||||||
) {}
|
?OperationRun $operationRun = null
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Execute the job.
|
||||||
@ -88,6 +103,17 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
|
|||||||
$bulkOperationService->recordSuccess($run);
|
$bulkOperationService->recordSuccess($run);
|
||||||
$bulkOperationService->complete($run);
|
$bulkOperationService->complete($run);
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
'succeeded',
|
||||||
|
['findings_created' => $created]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->notifyStatus($run->refresh());
|
$this->notifyStatus($run->refresh());
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('GenerateDriftFindingsJob: failed', [
|
Log::error('GenerateDriftFindingsJob: failed', [
|
||||||
@ -107,6 +133,14 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
|
|||||||
);
|
);
|
||||||
|
|
||||||
$bulkOperationService->fail($run, $e->getMessage());
|
$bulkOperationService->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
// TrackOperationRun middleware might catch this, but explicit fail ensures structure
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->failRun($this->operationRun, $e);
|
||||||
|
}
|
||||||
|
|
||||||
$this->notifyStatus($run->refresh());
|
$this->notifyStatus($run->refresh());
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
@ -124,55 +158,50 @@ private function notifyStatus(BulkOperationRun $run): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = 'failed';
|
$tenant = Tenant::query()->find((int) $run->tenant_id);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
$status = $run->statusBucket();
|
$status = $run->statusBucket();
|
||||||
} catch (Throwable) {
|
|
||||||
$failureEntries = $run->failures ?? [];
|
|
||||||
$hasNonSkippedFailure = false;
|
|
||||||
foreach ($failureEntries as $entry) {
|
|
||||||
if (! is_array($entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($entry['type'] ?? 'failed') !== 'skipped') {
|
$title = match ($status) {
|
||||||
$hasNonSkippedFailure = true;
|
'queued' => 'Drift generation queued',
|
||||||
break;
|
'running' => 'Drift generation started',
|
||||||
}
|
'succeeded' => 'Drift generation completed',
|
||||||
}
|
'partially succeeded' => 'Drift generation completed (partial)',
|
||||||
|
default => 'Drift generation failed',
|
||||||
$failedCount = (int) ($run->failed ?? 0);
|
|
||||||
$succeededCount = (int) ($run->succeeded ?? 0);
|
|
||||||
$hasFailures = $failedCount > 0 || $hasNonSkippedFailure;
|
|
||||||
|
|
||||||
if ($succeededCount > 0 && $hasFailures) {
|
|
||||||
$status = 'partially succeeded';
|
|
||||||
} elseif ($succeededCount === 0 && $hasFailures) {
|
|
||||||
$status = 'failed';
|
|
||||||
} else {
|
|
||||||
$status = match ($run->status) {
|
|
||||||
'pending' => 'queued',
|
|
||||||
'running' => 'running',
|
|
||||||
'completed', 'completed_with_errors' => 'succeeded',
|
|
||||||
default => 'failed',
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$run->user->notify(new RunStatusChangedNotification([
|
$body = sprintf(
|
||||||
'tenant_id' => (int) $run->tenant_id,
|
'Total: %d, processed: %d, succeeded: %d, failed: %d, skipped: %d.',
|
||||||
'run_type' => 'bulk_operation',
|
(int) $run->total_items,
|
||||||
'run_id' => (int) $run->getKey(),
|
(int) $run->processed_items,
|
||||||
'status' => $status,
|
(int) $run->succeeded,
|
||||||
'counts' => [
|
(int) $run->failed,
|
||||||
'total' => (int) $run->total_items,
|
(int) $run->skipped,
|
||||||
'processed' => (int) $run->processed_items,
|
);
|
||||||
'succeeded' => (int) $run->succeeded,
|
|
||||||
'failed' => (int) $run->failed,
|
$notification = Notification::make()
|
||||||
'skipped' => (int) $run->skipped,
|
->title($title)
|
||||||
],
|
->body($body)
|
||||||
]));
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : OperationRunLinks::index($tenant)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
match ($status) {
|
||||||
|
'succeeded' => $notification->success(),
|
||||||
|
'partially succeeded' => $notification->warning(),
|
||||||
|
'queued', 'running' => $notification->info(),
|
||||||
|
default => $notification->danger(),
|
||||||
|
};
|
||||||
|
|
||||||
|
$notification
|
||||||
|
->sendToDatabase($run->user)
|
||||||
|
->send();
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::warning('GenerateDriftFindingsJob: status notification failed', [
|
Log::warning('GenerateDriftFindingsJob: status notification failed', [
|
||||||
'tenant_id' => (int) $run->tenant_id,
|
'tenant_id' => (int) $run->tenant_id,
|
||||||
|
|||||||
61
app/Jobs/Middleware/TrackOperationRun.php
Normal file
61
app/Jobs/Middleware/TrackOperationRun.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs\Middleware;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
class TrackOperationRun
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Process the queued job.
|
||||||
|
*
|
||||||
|
* @param mixed $job
|
||||||
|
* @param callable $next
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function handle($job, Closure $next)
|
||||||
|
{
|
||||||
|
// Check if the job has an 'operationRun' property or method
|
||||||
|
$run = null;
|
||||||
|
|
||||||
|
if (method_exists($job, 'getOperationRun')) {
|
||||||
|
$run = $job->getOperationRun();
|
||||||
|
} elseif (property_exists($job, 'operationRun')) {
|
||||||
|
$run = $job->operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return $next($job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $service */
|
||||||
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
$service->updateRun($run, 'running');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $next($job);
|
||||||
|
|
||||||
|
// If the job was released back onto the queue (retry / delay), do not mark the run as completed.
|
||||||
|
if (property_exists($job, 'job') && $job->job && method_exists($job->job, 'isReleased') && $job->job->isReleased()) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the job didn't already mark it as completed/failed, we do it here.
|
||||||
|
// Re-fetch to check current status
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
if ($run->status === 'running') {
|
||||||
|
$service->updateRun($run, 'completed', 'succeeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$service->failRun($run, $e);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Jobs/PruneOldOperationRunsJob.php
Normal file
31
app/Jobs/PruneOldOperationRunsJob.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PruneOldOperationRunsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $retentionDays = 90
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
OperationRun::where('created_at', '<', now()->subDays($this->retentionDays))
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
301
app/Jobs/RemovePoliciesFromBackupSetJob.php
Normal file
301
app/Jobs/RemovePoliciesFromBackupSetJob.php
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class RemovePoliciesFromBackupSetJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,int> $backupItemIds
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $backupSetId,
|
||||||
|
public array $backupItemIds,
|
||||||
|
public int $initiatorUserId,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
AuditLogger $auditLogger,
|
||||||
|
BulkOperationService $bulkOperationService,
|
||||||
|
): void {
|
||||||
|
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
|
||||||
|
|
||||||
|
if (! $backupSet instanceof BackupSet) {
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
['backup_set_id' => $this->backupSetId],
|
||||||
|
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = $backupSet->tenant;
|
||||||
|
|
||||||
|
$initiator = User::query()->find($this->initiatorUserId);
|
||||||
|
|
||||||
|
$requestedIds = collect($this->backupItemIds)
|
||||||
|
->map(fn (mixed $value): int => (int) $value)
|
||||||
|
->filter(fn (int $value): bool => $value > 0)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$requestedCount = count($requestedIds);
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var \Illuminate\Database\Eloquent\Collection<int, BackupItem> $items */
|
||||||
|
$items = BackupItem::query()
|
||||||
|
->where('backup_set_id', $backupSet->getKey())
|
||||||
|
->whereIn('id', $requestedIds)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$foundIds = $items->pluck('id')->map(fn (mixed $value): int => (int) $value)->all();
|
||||||
|
$missingIds = array_values(array_diff($requestedIds, $foundIds));
|
||||||
|
|
||||||
|
foreach ($missingIds as $missingId) {
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'backup_item.not_found',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason("Backup item {$missingId} not found (already removed?)."),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$removed = 0;
|
||||||
|
$policyIds = [];
|
||||||
|
$policyIdentifiers = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->delete();
|
||||||
|
$removed++;
|
||||||
|
|
||||||
|
if ($item->policy_id) {
|
||||||
|
$policyIds[] = (int) $item->policy_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($item->policy_identifier) {
|
||||||
|
$policyIdentifiers[] = (string) $item->policy_identifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupSet->update([
|
||||||
|
'item_count' => $backupSet->items()->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'backup.items_removed',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $backupSet->getKey(),
|
||||||
|
status: 'success',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'removed_count' => $removed,
|
||||||
|
'requested_count' => $requestedCount,
|
||||||
|
'missing_count' => count($missingIds),
|
||||||
|
'policy_ids' => array_values(array_unique($policyIds)),
|
||||||
|
'policy_identifiers' => array_values(array_unique($policyIdentifiers)),
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'initiator_user_id' => $initiator?->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $initiator?->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$outcome = 'succeeded';
|
||||||
|
if ($removed === 0) {
|
||||||
|
$outcome = 'failed';
|
||||||
|
} elseif ($failures !== []) {
|
||||||
|
$outcome = 'partially_succeeded';
|
||||||
|
}
|
||||||
|
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$outcome,
|
||||||
|
[
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'requested' => $requestedCount,
|
||||||
|
'removed' => $removed,
|
||||||
|
'missing' => count($missingIds),
|
||||||
|
'remaining' => (int) $backupSet->item_count,
|
||||||
|
],
|
||||||
|
$failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifyCompleted(
|
||||||
|
initiator: $initiator,
|
||||||
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
|
removed: $removed,
|
||||||
|
requested: $requestedCount,
|
||||||
|
missing: count($missingIds),
|
||||||
|
outcome: $outcome,
|
||||||
|
);
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: 'backup.items_removed',
|
||||||
|
resourceType: 'backup_set',
|
||||||
|
resourceId: (string) $backupSet->getKey(),
|
||||||
|
status: 'failed',
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'requested_count' => $requestedCount,
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: $initiator?->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->failRun($this->operationRun, $throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notifyFailed(
|
||||||
|
initiator: $initiator,
|
||||||
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
|
reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()),
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyCompleted(
|
||||||
|
?User $initiator,
|
||||||
|
?Tenant $tenant,
|
||||||
|
int $removed,
|
||||||
|
int $requested,
|
||||||
|
int $missing,
|
||||||
|
?string $outcome,
|
||||||
|
): void {
|
||||||
|
if (! $initiator instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->operationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = "Removed {$removed} policies";
|
||||||
|
|
||||||
|
if ($missing > 0) {
|
||||||
|
$message .= " ({$missing} missing)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requested !== $removed && $missing === 0) {
|
||||||
|
$skipped = max(0, $requested - $removed);
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " ({$skipped} not removed)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$message .= '.';
|
||||||
|
|
||||||
|
$partial = in_array((string) $outcome, ['partially_succeeded'], true) || $missing > 0;
|
||||||
|
$failed = in_array((string) $outcome, ['failed'], true);
|
||||||
|
|
||||||
|
$notification = Notification::make()
|
||||||
|
->title($failed ? 'Removal failed' : ($partial ? 'Removal completed (partial)' : 'Removal completed'))
|
||||||
|
->body($message);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed) {
|
||||||
|
$notification->danger();
|
||||||
|
} elseif ($partial) {
|
||||||
|
$notification->warning();
|
||||||
|
} else {
|
||||||
|
$notification->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification
|
||||||
|
->sendToDatabase($initiator)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||||
|
{
|
||||||
|
if (! $initiator instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->operationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification = Notification::make()
|
||||||
|
->title('Removal failed')
|
||||||
|
->body($reason);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$notification
|
||||||
|
->danger()
|
||||||
|
->sendToDatabase($initiator)
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\RunErrorMapper;
|
use App\Services\BackupScheduling\RunErrorMapper;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
@ -12,8 +15,10 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Notifications\Events\DatabaseNotificationsSent;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -30,10 +35,20 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public int $tries = 3;
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $backupScheduleRunId,
|
public int $backupScheduleRunId,
|
||||||
public ?int $bulkRunId = null,
|
public ?int $bulkRunId = null,
|
||||||
) {}
|
?OperationRun $operationRun = null,
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
PolicySyncService $policySyncService,
|
PolicySyncService $policySyncService,
|
||||||
@ -49,9 +64,41 @@ public function handle(
|
|||||||
->find($this->backupScheduleRunId);
|
->find($this->backupScheduleRunId);
|
||||||
|
|
||||||
if (! $run) {
|
if (! $run) {
|
||||||
|
if ($this->operationRun) {
|
||||||
|
$this->markOperationRunFailed(
|
||||||
|
run: $this->operationRun,
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
summaryCounts: [],
|
||||||
|
reasonCode: 'RUN_NOT_FOUND',
|
||||||
|
reason: 'Backup schedule run not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$this->resolveOperationRunFromContext($tenant, $run);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
$this->operationRun->update([
|
||||||
|
'context' => array_merge($this->operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
if ($this->operationRun->status === 'queued') {
|
||||||
|
$operationRunService->updateRun($this->operationRun, 'running');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$bulkRun = $this->bulkRunId
|
$bulkRun = $this->bulkRunId
|
||||||
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
|
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
|
||||||
: null;
|
: null;
|
||||||
@ -77,10 +124,21 @@ public function handle(
|
|||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
'finished_at' => CarbonImmutable::now('UTC'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
if ($this->operationRun) {
|
||||||
|
$this->markOperationRunFailed(
|
||||||
|
run: $this->operationRun,
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
reasonCode: 'SCHEDULE_NOT_FOUND',
|
||||||
|
reason: 'Schedule not found.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $run->tenant;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$run->update([
|
$run->update([
|
||||||
@ -90,6 +148,19 @@ public function handle(
|
|||||||
'finished_at' => CarbonImmutable::now('UTC'),
|
'finished_at' => CarbonImmutable::now('UTC'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
$this->markOperationRunFailed(
|
||||||
|
run: $this->operationRun,
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
summaryCounts: [
|
||||||
|
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
reasonCode: 'TENANT_NOT_FOUND',
|
||||||
|
reason: 'Tenant not found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +178,13 @@ public function handle(
|
|||||||
bulkRunId: $this->bulkRunId,
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->syncOperationRunFromRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
schedule: $schedule,
|
||||||
|
run: $run->refresh(),
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +231,13 @@ public function handle(
|
|||||||
bulkRunId: $this->bulkRunId,
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->syncOperationRunFromRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
schedule: $schedule,
|
||||||
|
run: $run->refresh(),
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,6 +297,13 @@ public function handle(
|
|||||||
bulkRunId: $this->bulkRunId,
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->syncOperationRunFromRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
schedule: $schedule,
|
||||||
|
run: $run->refresh(),
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_finished',
|
action: 'backup_schedule.run_finished',
|
||||||
@ -232,6 +324,12 @@ public function handle(
|
|||||||
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
||||||
|
|
||||||
if ($mapped['shouldRetry']) {
|
if ($mapped['shouldRetry']) {
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
$this->release($mapped['delay']);
|
$this->release($mapped['delay']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -251,6 +349,13 @@ public function handle(
|
|||||||
bulkRunId: $this->bulkRunId,
|
bulkRunId: $this->bulkRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->syncOperationRunFromRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
schedule: $schedule,
|
||||||
|
run: $run->refresh(),
|
||||||
|
bulkOperationService: $bulkOperationService,
|
||||||
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'backup_schedule.run_failed',
|
action: 'backup_schedule.run_failed',
|
||||||
@ -281,10 +386,14 @@ private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedu
|
|||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title('Backup started')
|
->title('Backup started')
|
||||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
||||||
->info();
|
->info()
|
||||||
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
||||||
|
]);
|
||||||
|
|
||||||
$user->notifyNow($notification->toDatabase());
|
$notification->sendToDatabase($user);
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
|
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
|
||||||
@ -316,8 +425,148 @@ private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $sched
|
|||||||
default => $notification->danger(),
|
default => $notification->danger(),
|
||||||
};
|
};
|
||||||
|
|
||||||
$user->notifyNow($notification->toDatabase());
|
$notification
|
||||||
DatabaseNotificationsSent::dispatch($user);
|
->actions([
|
||||||
|
Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
||||||
|
])
|
||||||
|
->sendToDatabase($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncOperationRunFromRun(
|
||||||
|
Tenant $tenant,
|
||||||
|
BackupSchedule $schedule,
|
||||||
|
BackupScheduleRun $run,
|
||||||
|
BulkOperationService $bulkOperationService,
|
||||||
|
): void {
|
||||||
|
if (! $this->operationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outcome = match ($run->status) {
|
||||||
|
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||||
|
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||||
|
// Note: 'cancelled' is a reserved OperationRun outcome token.
|
||||||
|
// We treat schedule SKIPPED/CANCELED as terminal failures with a failure entry.
|
||||||
|
BackupScheduleRun::STATUS_SKIPPED,
|
||||||
|
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||||
|
default => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
$summary = is_array($run->summary) ? $run->summary : [];
|
||||||
|
$syncFailures = $summary['sync_failures'] ?? [];
|
||||||
|
|
||||||
|
$summaryCounts = [
|
||||||
|
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
|
||||||
|
'policies_total' => (int) ($summary['policies_total'] ?? 0),
|
||||||
|
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
|
||||||
|
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
|
||||||
|
if (filled($run->error_message) || filled($run->error_code)) {
|
||||||
|
$failures[] = [
|
||||||
|
'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($syncFailures)) {
|
||||||
|
foreach ($syncFailures as $failure) {
|
||||||
|
if (! is_array($failure)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||||
|
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||||
|
$errors = $failure['errors'] ?? null;
|
||||||
|
|
||||||
|
$firstErrorMessage = null;
|
||||||
|
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||||
|
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $status !== null
|
||||||
|
? "{$policyType}: Graph returned {$status}"
|
||||||
|
: "{$policyType}: Graph request failed";
|
||||||
|
|
||||||
|
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||||
|
$message .= ' - '.trim($firstErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures[] = [
|
||||||
|
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$this->operationRun->update([
|
||||||
|
'context' => array_merge($this->operationRun->context ?? [], [
|
||||||
|
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $outcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markOperationRunFailed(
|
||||||
|
OperationRun $run,
|
||||||
|
BulkOperationService $bulkOperationService,
|
||||||
|
array $summaryCounts,
|
||||||
|
string $reasonCode,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$operationRunService->updateRun(
|
||||||
|
$run,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => $reasonCode,
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason($reason),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOperationRunFromContext(Tenant $tenant, BackupScheduleRun $run): void
|
||||||
|
{
|
||||||
|
if ($this->operationRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||||
|
->whereIn('status', ['queued', 'running'])
|
||||||
|
->where('context->backup_schedule_run_id', (int) $run->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($operationRun instanceof OperationRun) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function finishRun(
|
private function finishRun(
|
||||||
|
|||||||
@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -21,6 +25,8 @@ class RunInventorySyncJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
@ -29,7 +35,20 @@ public function __construct(
|
|||||||
public int $userId,
|
public int $userId,
|
||||||
public int $bulkRunId,
|
public int $bulkRunId,
|
||||||
public int $inventorySyncRunId,
|
public int $inventorySyncRunId,
|
||||||
) {}
|
?OperationRun $operationRun = null
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the middleware the job should pass through.
|
||||||
|
*
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the job.
|
* Execute the job.
|
||||||
@ -60,6 +79,11 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
|
|||||||
|
|
||||||
$processedPolicyTypes = [];
|
$processedPolicyTypes = [];
|
||||||
|
|
||||||
|
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
||||||
|
// It will also handle success completion if no exceptions thrown.
|
||||||
|
// However, InventorySyncService execution logic might be complex with partial failures.
|
||||||
|
// We might want to explicitly update the OperationRun if partial failures occur.
|
||||||
|
|
||||||
$run = $inventorySyncService->executePendingRun(
|
$run = $inventorySyncService->executePendingRun(
|
||||||
$run,
|
$run,
|
||||||
$tenant,
|
$tenant,
|
||||||
@ -81,9 +105,31 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
|
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helper to update OperationRun with rich context ---
|
||||||
|
$updateOpRun = function (string $outcome, array $counts = [], array $failures = []) {
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$outcome,
|
||||||
|
$counts,
|
||||||
|
$failures
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// -----------------------------------------------------
|
||||||
|
|
||||||
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
||||||
$bulkOperationService->complete($bulkRun);
|
$bulkOperationService->complete($bulkRun);
|
||||||
|
|
||||||
|
// Update Operation Run explicitly to provide counts
|
||||||
|
$updateOpRun('succeeded', [
|
||||||
|
'observed' => $run->items_observed_count,
|
||||||
|
'upserted' => $run->items_upserted_count,
|
||||||
|
]);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.completed',
|
action: 'inventory.sync.completed',
|
||||||
@ -107,6 +153,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
->title('Inventory sync completed')
|
->title('Inventory sync completed')
|
||||||
->body('Inventory sync finished successfully.')
|
->body('Inventory sync finished successfully.')
|
||||||
->success()
|
->success()
|
||||||
|
->actions($this->operationRun ? [
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
] : [])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -116,6 +167,15 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
|
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
|
||||||
$bulkOperationService->complete($bulkRun);
|
$bulkOperationService->complete($bulkRun);
|
||||||
|
|
||||||
|
$updateOpRun('partially_succeeded', [
|
||||||
|
'observed' => $run->items_observed_count,
|
||||||
|
'upserted' => $run->items_upserted_count,
|
||||||
|
'errors' => $run->errors_count,
|
||||||
|
], [
|
||||||
|
// Minimal error summary
|
||||||
|
['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"],
|
||||||
|
]);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.partial',
|
action: 'inventory.sync.partial',
|
||||||
@ -141,6 +201,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
->title('Inventory sync completed with errors')
|
->title('Inventory sync completed with errors')
|
||||||
->body('Inventory sync finished with some errors. Review the run details for error codes.')
|
->body('Inventory sync finished with some errors. Review the run details for error codes.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions($this->operationRun ? [
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
] : [])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -155,6 +220,8 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
}
|
}
|
||||||
$bulkOperationService->complete($bulkRun);
|
$bulkOperationService->complete($bulkRun);
|
||||||
|
|
||||||
|
$updateOpRun('failed', [], [['code' => 'SKIPPED', 'message' => $reason]]);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.skipped',
|
action: 'inventory.sync.skipped',
|
||||||
@ -177,6 +244,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
->title('Inventory sync skipped')
|
->title('Inventory sync skipped')
|
||||||
->body('Inventory sync could not start due to locks or concurrency limits.')
|
->body('Inventory sync could not start due to locks or concurrency limits.')
|
||||||
->warning()
|
->warning()
|
||||||
|
->actions($this->operationRun ? [
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
] : [])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -192,6 +264,8 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
|
|
||||||
$bulkOperationService->complete($bulkRun);
|
$bulkOperationService->complete($bulkRun);
|
||||||
|
|
||||||
|
$updateOpRun('failed', [], [['code' => 'FAILED', 'message' => $reason]]);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.failed',
|
action: 'inventory.sync.failed',
|
||||||
@ -215,6 +289,11 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
->title('Inventory sync failed')
|
->title('Inventory sync failed')
|
||||||
->body('Inventory sync finished with errors.')
|
->body('Inventory sync finished with errors.')
|
||||||
->danger()
|
->danger()
|
||||||
|
->actions($this->operationRun ? [
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||||
|
] : [])
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -14,24 +19,162 @@ class SyncPoliciesJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string>|null $types
|
* @param array<int, string>|null $types
|
||||||
|
* @param array<int, int>|null $policyIds
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $tenantId,
|
public readonly int $tenantId,
|
||||||
public readonly ?array $types = null,
|
public readonly ?array $types = null,
|
||||||
) {}
|
public readonly ?array $policyIds = null,
|
||||||
|
?OperationRun $operationRun = null
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(PolicySyncService $service): void
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [new TrackOperationRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::findOrFail($this->tenantId);
|
$tenant = Tenant::findOrFail($this->tenantId);
|
||||||
|
|
||||||
$supported = config('tenantpilot.supported_policy_types');
|
if ($this->policyIds !== null) {
|
||||||
|
$ids = collect($this->policyIds)
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$syncedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$failureSummary = [];
|
||||||
|
|
||||||
|
foreach ($ids as $policyId) {
|
||||||
|
$policy = Policy::query()
|
||||||
|
->whereKey($policyId)
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $policy) {
|
||||||
|
$failureSummary[] = [
|
||||||
|
'code' => 'policy.not_found',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"),
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($policy->ignored_at !== null) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->syncPolicy($tenant, $policy);
|
||||||
|
|
||||||
|
$syncedCount++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failureSummary[] = [
|
||||||
|
'code' => 'policy.sync_failed',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$failureCount = count($failureSummary);
|
||||||
|
$outcome = match (true) {
|
||||||
|
$failureCount === 0 => 'succeeded',
|
||||||
|
$syncedCount > 0 => 'partially_succeeded',
|
||||||
|
default => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$outcome,
|
||||||
|
[
|
||||||
|
'policies_total' => $ids->count(),
|
||||||
|
'policies_synced' => $syncedCount,
|
||||||
|
'policies_skipped' => $skippedCount,
|
||||||
|
'policies_failed' => $failureCount,
|
||||||
|
],
|
||||||
|
$failureSummary
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$supported = config('tenantpilot.supported_policy_types', []);
|
||||||
|
|
||||||
if ($this->types !== null) {
|
if ($this->types !== null) {
|
||||||
$supported = array_values(array_filter($supported, fn ($type) => in_array($type['type'], $this->types, true)));
|
$supported = array_values(array_filter($supported, fn ($type) => in_array($type['type'], $this->types, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->syncPolicies($tenant, $supported);
|
$result = $service->syncPoliciesWithReport($tenant, $supported);
|
||||||
|
$syncedCount = count($result['synced'] ?? []);
|
||||||
|
$failures = $result['failures'] ?? [];
|
||||||
|
$failureCount = count($failures);
|
||||||
|
|
||||||
|
$outcome = match (true) {
|
||||||
|
$failureCount === 0 => 'succeeded',
|
||||||
|
$syncedCount > 0 => 'partially_succeeded',
|
||||||
|
default => 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
$failureSummary = [];
|
||||||
|
|
||||||
|
foreach ($failures as $failure) {
|
||||||
|
if (! is_array($failure)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||||
|
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||||
|
$errors = $failure['errors'] ?? null;
|
||||||
|
|
||||||
|
$firstErrorMessage = null;
|
||||||
|
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||||
|
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = $status !== null
|
||||||
|
? "{$policyType}: Graph returned {$status}"
|
||||||
|
: "{$policyType}: Graph request failed";
|
||||||
|
|
||||||
|
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||||
|
$message .= ' - '.trim($firstErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$failureSummary[] = [
|
||||||
|
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||||
|
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->operationRun) {
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
'completed',
|
||||||
|
$outcome,
|
||||||
|
[
|
||||||
|
'policy_types_total' => count($supported),
|
||||||
|
'policies_synced' => $syncedCount,
|
||||||
|
'policy_types_failed' => $failureCount,
|
||||||
|
],
|
||||||
|
$failureSummary
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
app/Listeners/SyncRestoreRunToOperation.php
Normal file
81
app/Listeners/SyncRestoreRunToOperation.php
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
|
class SyncRestoreRunToOperation implements ShouldQueue
|
||||||
|
{
|
||||||
|
use InteractsWithQueue;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected OperationRunService $service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(object $event): void
|
||||||
|
{
|
||||||
|
// We assume we might listen to Eloquent events directly via an observer or manually dispatched events.
|
||||||
|
// For now, let's assume we bind this to "RestoreRunCreated" and "RestoreRunUpdated" if they exist,
|
||||||
|
// or we treat this class as a generic handler invoked by an Observer.
|
||||||
|
|
||||||
|
// If the event itself HAS a restore run property:
|
||||||
|
$restoreRun = null;
|
||||||
|
if (property_exists($event, 'restoreRun')) {
|
||||||
|
$restoreRun = $event->restoreRun;
|
||||||
|
} elseif ($event instanceof RestoreRun) {
|
||||||
|
$restoreRun = $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $restoreRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputs = [
|
||||||
|
'restore_run_id' => $restoreRun->id,
|
||||||
|
'backup_set_id' => $restoreRun->backup_set_id,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ensure Run (Idempotent)
|
||||||
|
$opRun = $this->service->ensureRun(
|
||||||
|
tenant: $restoreRun->tenant,
|
||||||
|
type: 'restore.execute',
|
||||||
|
inputs: $inputs,
|
||||||
|
initiator: $restoreRun->user ?? null, // Assuming RestoreRun has user relation
|
||||||
|
initiatorName: $restoreRun->user->name ?? 'System'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map status
|
||||||
|
// RestoreRun status -> OperationRun status
|
||||||
|
$statusMap = [
|
||||||
|
'pending' => 'queued',
|
||||||
|
'queued' => 'queued',
|
||||||
|
'running' => 'running',
|
||||||
|
'completed' => 'completed',
|
||||||
|
'failed' => 'completed',
|
||||||
|
'cancelled' => 'completed',
|
||||||
|
'partially_succeeded' => 'completed',
|
||||||
|
];
|
||||||
|
|
||||||
|
$outcomeMap = [
|
||||||
|
'completed' => 'succeeded',
|
||||||
|
'failed' => 'failed',
|
||||||
|
'cancelled' => 'cancelled',
|
||||||
|
'partially_succeeded' => 'partially_succeeded',
|
||||||
|
];
|
||||||
|
|
||||||
|
$newStatus = $statusMap[$restoreRun->status] ?? 'running';
|
||||||
|
$newOutcome = $outcomeMap[$restoreRun->status] ?? 'pending';
|
||||||
|
|
||||||
|
$this->service->updateRun(
|
||||||
|
run: $opRun,
|
||||||
|
status: $newStatus,
|
||||||
|
outcome: $newOutcome,
|
||||||
|
// We could map counts/failures here if available on RestoreRun
|
||||||
|
summaryCounts: $restoreRun->summary_counts ?? [],
|
||||||
|
failures: $restoreRun->failures ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Listeners/SyncRestoreRunToOperationRun.php
Normal file
88
app/Listeners/SyncRestoreRunToOperationRun.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
|
||||||
|
class SyncRestoreRunToOperationRun
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public OperationRunService $service
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(RestoreRun $restoreRun): void
|
||||||
|
{
|
||||||
|
$status = RestoreRunStatus::fromString($restoreRun->status);
|
||||||
|
|
||||||
|
if (! $status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapter row becomes visible from "previewed" onward.
|
||||||
|
if (! in_array($status, [
|
||||||
|
RestoreRunStatus::Previewed,
|
||||||
|
RestoreRunStatus::Pending,
|
||||||
|
RestoreRunStatus::Queued,
|
||||||
|
RestoreRunStatus::Running,
|
||||||
|
RestoreRunStatus::Completed,
|
||||||
|
RestoreRunStatus::Partial,
|
||||||
|
RestoreRunStatus::Failed,
|
||||||
|
RestoreRunStatus::Cancelled,
|
||||||
|
RestoreRunStatus::Aborted,
|
||||||
|
RestoreRunStatus::CompletedWithErrors,
|
||||||
|
], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputs = [
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $restoreRun->backup_set_id,
|
||||||
|
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
||||||
|
];
|
||||||
|
|
||||||
|
$opRun = $this->service->ensureRun(
|
||||||
|
tenant: $restoreRun->tenant,
|
||||||
|
type: 'restore.execute',
|
||||||
|
inputs: $inputs,
|
||||||
|
initiator: null
|
||||||
|
);
|
||||||
|
|
||||||
|
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
|
||||||
|
|
||||||
|
$summaryCounts = [
|
||||||
|
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(),
|
||||||
|
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
|
||||||
|
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->service->updateRun(
|
||||||
|
$opRun,
|
||||||
|
status: $opStatus,
|
||||||
|
outcome: $opOutcome,
|
||||||
|
summaryCounts: $summaryCounts,
|
||||||
|
failures: $failures
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: string, 1: string, 2: array<int, array{code: string, message: string}>}
|
||||||
|
*/
|
||||||
|
protected function mapStatus(RestoreRunStatus $status): array
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
RestoreRunStatus::Previewed => ['queued', 'pending', []],
|
||||||
|
RestoreRunStatus::Pending => ['queued', 'pending', []],
|
||||||
|
RestoreRunStatus::Queued => ['queued', 'pending', []],
|
||||||
|
RestoreRunStatus::Running => ['running', 'pending', []],
|
||||||
|
RestoreRunStatus::Completed => ['completed', 'succeeded', []],
|
||||||
|
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => ['completed', 'partially_succeeded', []],
|
||||||
|
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => ['completed', 'failed', []],
|
||||||
|
RestoreRunStatus::Cancelled => ['completed', 'failed', [
|
||||||
|
['code' => 'restore.cancelled', 'message' => 'Restore run was cancelled.'],
|
||||||
|
]],
|
||||||
|
default => ['running', 'pending', []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
@ -10,6 +9,8 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\RunIdempotency;
|
use App\Support\RunIdempotency;
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -277,6 +278,40 @@ public function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Phase 3: Canonical Operation Run Start ---
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_set.add_policies',
|
||||||
|
inputs: [
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_ids' => $policyIds,
|
||||||
|
'options' => [
|
||||||
|
'include_assignments' => (bool) $this->include_assignments,
|
||||||
|
'include_scope_tags' => (bool) $this->include_scope_tags,
|
||||||
|
'include_foundations' => (bool) $this->include_foundations,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Add policies already queued')
|
||||||
|
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ----------------------------------------------
|
||||||
|
|
||||||
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
idempotencyKey: $idempotencyKey,
|
idempotencyKey: $idempotencyKey,
|
||||||
@ -289,7 +324,7 @@ public function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
@ -331,7 +366,7 @@ public function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
@ -343,13 +378,18 @@ public function table(Table $table): Table
|
|||||||
throw $exception;
|
throw $exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var OperationRunService $opService */
|
||||||
|
$opService = app(OperationRunService::class);
|
||||||
|
$opService->dispatchOrFail($opRun, function () use ($run, $backupSet, $opRun): void {
|
||||||
AddPoliciesToBackupSetJob::dispatch(
|
AddPoliciesToBackupSetJob::dispatch(
|
||||||
bulkRunId: (int) $run->getKey(),
|
bulkRunId: (int) $run->getKey(),
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
includeAssignments: (bool) $this->include_assignments,
|
includeAssignments: (bool) $this->include_assignments,
|
||||||
includeScopeTags: (bool) $this->include_scope_tags,
|
includeScopeTags: (bool) $this->include_scope_tags,
|
||||||
includeFoundations: (bool) $this->include_foundations,
|
includeFoundations: (bool) $this->include_foundations,
|
||||||
|
operationRun: $opRun
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
$notificationTitle = $this->include_foundations
|
$notificationTitle = $this->include_foundations
|
||||||
? 'Backup items queued'
|
? 'Backup items queued'
|
||||||
@ -361,13 +401,16 @@ public function table(Table $table): Table
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->success()
|
->success()
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
|
|
||||||
|
$this->dispatch('backup-set-policy-picker:close')
|
||||||
|
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/Livewire/Monitoring/OperationsDetail.php
Normal file
28
app/Livewire/Monitoring/OperationsDetail.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Monitoring;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class OperationsDetail extends Component implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
public OperationRun $run;
|
||||||
|
|
||||||
|
public function mount(OperationRun $run): void
|
||||||
|
{
|
||||||
|
// Ensure tenant scope
|
||||||
|
abort_unless($run->tenant_id === filament()->getTenant()->id, 403);
|
||||||
|
$this->run = $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.monitoring.operations-detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Models/OperationRun.php
Normal file
38
app/Models/OperationRun.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OperationRun extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'summary_counts' => 'array',
|
||||||
|
'failure_summary' => 'array',
|
||||||
|
'context' => 'array',
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', ['queued', 'running']);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Notifications/OperationRunCompleted.php
Normal file
46
app/Notifications/OperationRunCompleted.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class OperationRunCompleted extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public OperationRun $run
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toDatabase(object $notifiable): array
|
||||||
|
{
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
$status = match ((string) $this->run->outcome) {
|
||||||
|
'succeeded' => 'success',
|
||||||
|
'partially_succeeded' => 'warning',
|
||||||
|
default => 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title('Operation completed')
|
||||||
|
->body("{$this->run->type} ({$this->run->outcome})")
|
||||||
|
->status($status)
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view')
|
||||||
|
->label('View run')
|
||||||
|
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
|
||||||
|
])
|
||||||
|
->getDatabaseMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Notifications/OperationRunQueued.php
Normal file
46
app/Notifications/OperationRunQueued.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class OperationRunQueued extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public OperationRun $run
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['database'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toDatabase(object $notifiable): array
|
||||||
|
{
|
||||||
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title('Operation queued')
|
||||||
|
->body($this->run->type)
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url($tenant instanceof Tenant ? OperationRunLinks::view($this->run, $tenant) : null),
|
||||||
|
])
|
||||||
|
->getDatabaseMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Observers/RestoreRunObserver.php
Normal file
32
app/Observers/RestoreRunObserver.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Listeners\SyncRestoreRunToOperationRun;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
|
||||||
|
class RestoreRunObserver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the RestoreRun "created" event.
|
||||||
|
*/
|
||||||
|
public function created(RestoreRun $restoreRun): void
|
||||||
|
{
|
||||||
|
$this->sync($restoreRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the RestoreRun "updated" event.
|
||||||
|
*/
|
||||||
|
public function updated(RestoreRun $restoreRun): void
|
||||||
|
{
|
||||||
|
$this->sync($restoreRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sync(RestoreRun $restoreRun): void
|
||||||
|
{
|
||||||
|
/** @var SyncRestoreRunToOperationRun $syncer */
|
||||||
|
$syncer = app(SyncRestoreRunToOperationRun::class);
|
||||||
|
$syncer->handle($restoreRun);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Policies/OperationRunPolicy.php
Normal file
39
app/Policies/OperationRunPolicy.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class OperationRunPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, OperationRun $run): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $run->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,14 +7,18 @@
|
|||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
|
use App\Observers\RestoreRunObserver;
|
||||||
use App\Policies\BackupSchedulePolicy;
|
use App\Policies\BackupSchedulePolicy;
|
||||||
use App\Policies\BulkOperationRunPolicy;
|
use App\Policies\BulkOperationRunPolicy;
|
||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
use App\Policies\EntraGroupSyncRunPolicy;
|
use App\Policies\EntraGroupSyncRunPolicy;
|
||||||
use App\Policies\FindingPolicy;
|
use App\Policies\FindingPolicy;
|
||||||
|
use App\Policies\OperationRunPolicy;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\MicrosoftGraphClient;
|
use App\Services\Graph\MicrosoftGraphClient;
|
||||||
use App\Services\Graph\NullGraphClient;
|
use App\Services\Graph\NullGraphClient;
|
||||||
@ -83,6 +87,8 @@ public function register(): void
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
RestoreRun::observe(RestoreRunObserver::class);
|
||||||
|
|
||||||
Event::listen(TenantSet::class, function (TenantSet $event): void {
|
Event::listen(TenantSet::class, function (TenantSet $event): void {
|
||||||
static $hasPreferencesTable;
|
static $hasPreferencesTable;
|
||||||
|
|
||||||
@ -119,5 +125,6 @@ public function boot(): void
|
|||||||
Gate::policy(Finding::class, FindingPolicy::class);
|
Gate::policy(Finding::class, FindingPolicy::class);
|
||||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||||
|
Gate::policy(OperationRun::class, OperationRunPolicy::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,9 @@ public function panel(Panel $panel): Panel
|
|||||||
])
|
])
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_END,
|
PanelsRenderHook::BODY_END,
|
||||||
fn () => view('livewire.bulk-operation-progress-wrapper')->render()
|
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||||
|
? view('livewire.bulk-operation-progress-wrapper')->render()
|
||||||
|
: ''
|
||||||
)
|
)
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
|
|||||||
282
app/Services/OperationRunService.php
Normal file
282
app/Services/OperationRunService.php
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||||
|
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OperationRunService
|
||||||
|
{
|
||||||
|
public function ensureRun(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $type,
|
||||||
|
array $inputs,
|
||||||
|
?User $initiator = null
|
||||||
|
): OperationRun {
|
||||||
|
$hash = $this->calculateHash($tenant->id, $type, $inputs);
|
||||||
|
|
||||||
|
// Idempotency Check (Fast Path)
|
||||||
|
// We check specific status to match the partial unique index
|
||||||
|
$existing = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('run_identity_hash', $hash)
|
||||||
|
->whereIn('status', OperationRunStatus::values())
|
||||||
|
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new run (race-safe via partial unique index)
|
||||||
|
try {
|
||||||
|
return OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => $initiator?->id,
|
||||||
|
'initiator_name' => $initiator?->name ?? 'System',
|
||||||
|
'type' => $type,
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'run_identity_hash' => $hash,
|
||||||
|
'context' => $inputs,
|
||||||
|
]);
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
// Unique violation (active-run dedupe):
|
||||||
|
// - PostgreSQL: 23505
|
||||||
|
// - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
|
||||||
|
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('run_identity_hash', $hash)
|
||||||
|
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $status,
|
||||||
|
?string $outcome = null,
|
||||||
|
array $summaryCounts = [],
|
||||||
|
array $failures = []
|
||||||
|
): OperationRun {
|
||||||
|
$previousStatus = (string) $run->status;
|
||||||
|
|
||||||
|
if (! in_array($status, OperationRunStatus::values(), true)) {
|
||||||
|
throw new InvalidArgumentException('Invalid OperationRun status: '.$status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($outcome !== null) {
|
||||||
|
if (! in_array($outcome, OperationRunOutcome::values(), true)) {
|
||||||
|
throw new InvalidArgumentException('Invalid OperationRun outcome: '.$outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved/future: MUST NOT be produced by feature 054.
|
||||||
|
if ($outcome === OperationRunOutcome::Cancelled->value) {
|
||||||
|
$outcome = OperationRunOutcome::Failed->value;
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'run.cancelled',
|
||||||
|
'message' => 'Run cancelled (reserved outcome mapped to failed).',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($outcome) {
|
||||||
|
$updateData['outcome'] = $outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($summaryCounts)) {
|
||||||
|
$updateData['summary_counts'] = $summaryCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($failures)) {
|
||||||
|
$updateData['failure_summary'] = $this->sanitizeFailures($failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === OperationRunStatus::Running->value && is_null($run->started_at)) {
|
||||||
|
$updateData['started_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === OperationRunStatus::Completed->value && is_null($run->completed_at)) {
|
||||||
|
$updateData['completed_at'] = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$run->update($updateData);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
if ($previousStatus !== OperationRunStatus::Completed->value
|
||||||
|
&& $run->status === OperationRunStatus::Completed->value
|
||||||
|
&& $run->user instanceof User
|
||||||
|
) {
|
||||||
|
$run->user->notify(new OperationRunCompletedNotification($run));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a queued operation safely.
|
||||||
|
*
|
||||||
|
* If dispatch fails synchronously (misconfiguration, serialization errors, etc.),
|
||||||
|
* the OperationRun is marked terminal failed so we do not leave a misleading queued run behind.
|
||||||
|
*/
|
||||||
|
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dispatcher();
|
||||||
|
|
||||||
|
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
|
||||||
|
$run->user->notify(new OperationRunQueuedNotification($run));
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->updateRun(
|
||||||
|
$run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'dispatch.failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||||
|
{
|
||||||
|
return $this->updateRun(
|
||||||
|
$run,
|
||||||
|
status: OperationRunStatus::Completed->value,
|
||||||
|
outcome: OperationRunOutcome::Failed->value,
|
||||||
|
failures: [
|
||||||
|
[
|
||||||
|
'code' => 'exception.unhandled',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function calculateHash(int $tenantId, string $type, array $inputs): string
|
||||||
|
{
|
||||||
|
$normalizedInputs = $this->normalizeInputs($inputs);
|
||||||
|
|
||||||
|
$json = json_encode($normalizedInputs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return hash('sha256', $tenantId.'|'.$type.'|'.$json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize inputs for stable identity hashing.
|
||||||
|
*
|
||||||
|
* - Associative arrays: sorted by key.
|
||||||
|
* - Lists: elements normalized and then sorted by a stable JSON representation.
|
||||||
|
*/
|
||||||
|
protected function normalizeInputs(array $value): array
|
||||||
|
{
|
||||||
|
if ($this->isListArray($value)) {
|
||||||
|
$items = array_map(function ($item) {
|
||||||
|
return is_array($item) ? $this->normalizeInputs($item) : $item;
|
||||||
|
}, $value);
|
||||||
|
|
||||||
|
usort($items, function ($a, $b): int {
|
||||||
|
$aJson = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
$bJson = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
return strcmp((string) $aJson, (string) $bJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($value);
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$value[$key] = $this->normalizeInputs($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isListArray(array $array): bool
|
||||||
|
{
|
||||||
|
if ($array === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($array) === range(0, count($array) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{code?: mixed, message?: mixed}> $failures
|
||||||
|
* @return array<int, array{code: string, message: string}>
|
||||||
|
*/
|
||||||
|
protected function sanitizeFailures(array $failures): array
|
||||||
|
{
|
||||||
|
$sanitized = [];
|
||||||
|
|
||||||
|
foreach ($failures as $failure) {
|
||||||
|
$code = (string) ($failure['code'] ?? 'unknown');
|
||||||
|
$message = (string) ($failure['message'] ?? '');
|
||||||
|
|
||||||
|
$sanitized[] = [
|
||||||
|
'code' => $this->sanitizeFailureCode($code),
|
||||||
|
'message' => $this->sanitizeMessage($message),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sanitizeFailureCode(string $code): string
|
||||||
|
{
|
||||||
|
$code = strtolower(trim($code));
|
||||||
|
|
||||||
|
if ($code === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($code, 0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function sanitizeMessage(string $message): string
|
||||||
|
{
|
||||||
|
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||||
|
|
||||||
|
// Redact obvious bearer tokens / secrets.
|
||||||
|
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
|
||||||
|
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
|
||||||
|
|
||||||
|
// Redact long opaque blobs that look token-like.
|
||||||
|
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||||
|
|
||||||
|
return substr($message, 0, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Support/OperationRunLinks.php
Normal file
84
app/Support/OperationRunLinks.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Filament\Pages\InventoryLanding;
|
||||||
|
use App\Filament\Resources\BackupScheduleResource;
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
final class OperationRunLinks
|
||||||
|
{
|
||||||
|
public static function index(Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return OperationRunResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function view(OperationRun|int $run, Tenant $tenant): string
|
||||||
|
{
|
||||||
|
return OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function related(OperationRun $run, Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
|
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
$links['Operations'] = self::index($tenant);
|
||||||
|
|
||||||
|
if ($run->type === 'inventory.sync') {
|
||||||
|
$links['Inventory'] = InventoryLanding::getUrl(tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($run->type, ['policy.sync', 'policy.sync_one'], true)) {
|
||||||
|
$links['Policies'] = PolicyResource::getUrl('index', tenant: $tenant);
|
||||||
|
|
||||||
|
$policyId = $context['policy_id'] ?? null;
|
||||||
|
if (is_numeric($policyId)) {
|
||||||
|
$links['Policy'] = PolicyResource::getUrl('view', ['record' => (int) $policyId], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'directory_groups.sync') {
|
||||||
|
$links['Directory Groups'] = EntraGroupResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'drift.generate') {
|
||||||
|
$links['Drift'] = DriftLanding::getUrl(tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($run->type, ['backup_set.add_policies', 'backup_set.remove_policies'], true)) {
|
||||||
|
$links['Backup Sets'] = BackupSetResource::getUrl('index', tenant: $tenant);
|
||||||
|
|
||||||
|
$backupSetId = $context['backup_set_id'] ?? null;
|
||||||
|
if (is_numeric($backupSetId)) {
|
||||||
|
$links['Backup Set'] = BackupSetResource::getUrl('view', ['record' => (int) $backupSetId], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
|
||||||
|
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', tenant: $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($run->type === 'restore.execute') {
|
||||||
|
$links['Restore Runs'] = RestoreRunResource::getUrl('index', tenant: $tenant);
|
||||||
|
|
||||||
|
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||||
|
if (is_numeric($restoreRunId)) {
|
||||||
|
$links['Restore Run'] = RestoreRunResource::getUrl('view', ['record' => (int) $restoreRunId], tenant: $tenant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter($links, static fn (?string $url): bool => is_string($url) && $url !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Support/OperationRunOutcome.php
Normal file
43
app/Support/OperationRunOutcome.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
enum OperationRunOutcome: string
|
||||||
|
{
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Succeeded = 'succeeded';
|
||||||
|
case PartiallySucceeded = 'partially_succeeded';
|
||||||
|
case Failed = 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved for future use. MUST NOT be produced by feature 054.
|
||||||
|
*/
|
||||||
|
case Cancelled = 'cancelled';
|
||||||
|
|
||||||
|
public static function values(bool $includeReserved = true): array
|
||||||
|
{
|
||||||
|
$cases = self::cases();
|
||||||
|
|
||||||
|
if (! $includeReserved) {
|
||||||
|
$cases = array_filter($cases, static fn (self $case): bool => $case !== self::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(static fn (self $case): string => $case->value, $cases);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function uiLabels(bool $includeReserved = false): array
|
||||||
|
{
|
||||||
|
$labels = [
|
||||||
|
self::Pending->value => 'Pending',
|
||||||
|
self::Succeeded->value => 'Succeeded',
|
||||||
|
self::PartiallySucceeded->value => 'Partially succeeded',
|
||||||
|
self::Failed->value => 'Failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($includeReserved) {
|
||||||
|
$labels[self::Cancelled->value] = 'Cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Support/OperationRunStatus.php
Normal file
15
app/Support/OperationRunStatus.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
enum OperationRunStatus: string
|
||||||
|
{
|
||||||
|
case Queued = 'queued';
|
||||||
|
case Running = 'running';
|
||||||
|
case Completed = 'completed';
|
||||||
|
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Support/OperationRunType.php
Normal file
22
app/Support/OperationRunType.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
enum OperationRunType: string
|
||||||
|
{
|
||||||
|
case InventorySync = 'inventory.sync';
|
||||||
|
case PolicySync = 'policy.sync';
|
||||||
|
case PolicySyncOne = 'policy.sync_one';
|
||||||
|
case DirectoryGroupsSync = 'directory_groups.sync';
|
||||||
|
case DriftGenerate = 'drift.generate';
|
||||||
|
case BackupSetAddPolicies = 'backup_set.add_policies';
|
||||||
|
case BackupSetRemovePolicies = 'backup_set.remove_policies';
|
||||||
|
case BackupScheduleRunNow = 'backup_schedule.run_now';
|
||||||
|
case BackupScheduleRetry = 'backup_schedule.retry';
|
||||||
|
case RestoreExecute = 'restore.execute';
|
||||||
|
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -318,6 +318,8 @@
|
|||||||
'bulk_operations' => [
|
'bulk_operations' => [
|
||||||
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
|
'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10),
|
||||||
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
||||||
|
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
|
||||||
|
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true),
|
||||||
],
|
],
|
||||||
|
|
||||||
'inventory_sync' => [
|
'inventory_sync' => [
|
||||||
|
|||||||
37
database/factories/OperationRunFactory.php
Normal file
37
database/factories/OperationRunFactory.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<OperationRun>
|
||||||
|
*/
|
||||||
|
class OperationRunFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = OperationRun::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'initiator_name' => fake()->name(),
|
||||||
|
'type' => fake()->randomElement(OperationRunType::values()),
|
||||||
|
'status' => OperationRunStatus::Queued->value,
|
||||||
|
'outcome' => OperationRunOutcome::Pending->value,
|
||||||
|
'run_identity_hash' => fake()->sha256(),
|
||||||
|
'summary_counts' => [],
|
||||||
|
'failure_summary' => [],
|
||||||
|
'context' => [],
|
||||||
|
'started_at' => null,
|
||||||
|
'completed_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('operation_runs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('initiator_name');
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('status');
|
||||||
|
$table->string('outcome')->default('pending');
|
||||||
|
$table->string('run_identity_hash');
|
||||||
|
$table->jsonb('summary_counts')->default('{}');
|
||||||
|
$table->jsonb('failure_summary')->default('[]');
|
||||||
|
$table->jsonb('context')->default('{}');
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['tenant_id', 'type', 'created_at']);
|
||||||
|
$table->index(['tenant_id', 'created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Partial unique index for idempotency
|
||||||
|
DB::statement("CREATE UNIQUE INDEX operation_runs_active_unique ON operation_runs (tenant_id, run_identity_hash) WHERE status IN ('queued', 'running')");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('operation_runs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -54,10 +54,10 @@
|
|||||||
Drift generation has been queued. Refresh this page once it finishes.
|
Drift generation has been queued. Refresh this page once it finishes.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($this->getBulkRunUrl())
|
@if ($this->getOperationRunUrl())
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
|
||||||
View run #{{ $bulkOperationRunId }}
|
View run #{{ $operationRunId }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -72,10 +72,10 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($this->getBulkRunUrl())
|
@if ($this->getOperationRunUrl())
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
<a class="text-primary-600 hover:underline" href="{{ $this->getOperationRunUrl() }}">
|
||||||
View run #{{ $bulkOperationRunId }}
|
View run #{{ $operationRunId }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<div wire:poll.{{ $pollSeconds }}s="loadRuns">
|
@php($runs = $runs ?? collect())
|
||||||
|
@php($interval = $runs->isEmpty() ? max((int) $pollSeconds, 10) : (int) $pollSeconds)
|
||||||
|
|
||||||
|
<div wire:poll.{{ $interval }}s="loadRuns">
|
||||||
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
|
<!-- Bulk Operation Progress Component: {{ $runs->count() }} active runs -->
|
||||||
@if($runs->isNotEmpty())
|
@if($runs->isNotEmpty())
|
||||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Summary</h3>
|
||||||
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<dt class="text-gray-600">Type:</dt>
|
||||||
|
<dd>{{ $run->type }}</dd>
|
||||||
|
|
||||||
|
<dt class="text-gray-600">Status:</dt>
|
||||||
|
<dd>{{ $run->status }}</dd>
|
||||||
|
|
||||||
|
<dt class="text-gray-600">Outcome:</dt>
|
||||||
|
<dd>{{ $run->outcome }}</dd>
|
||||||
|
|
||||||
|
<dt class="text-gray-600">Initiator:</dt>
|
||||||
|
<dd>{{ $run->initiator_name }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Timing</h3>
|
||||||
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<dt class="text-gray-600">Created:</dt>
|
||||||
|
<dd>{{ $run->created_at }}</dd>
|
||||||
|
|
||||||
|
<dt class="text-gray-600">Started:</dt>
|
||||||
|
<dd>{{ $run->started_at ?? '-' }}</dd>
|
||||||
|
|
||||||
|
<dt class="text-gray-600">Completed:</dt>
|
||||||
|
<dd>{{ $run->completed_at ?? '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(!empty($run->summary_counts))
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Counts</h3>
|
||||||
|
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->summary_counts, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($run->failure_summary))
|
||||||
|
<div class="bg-white p-4 rounded shadow border-l-4 border-red-500">
|
||||||
|
<h3 class="font-bold text-lg mb-2 text-red-700">Failures</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($run->failure_summary as $failure)
|
||||||
|
<div class="bg-red-50 p-2 rounded">
|
||||||
|
<div class="font-mono text-xs text-red-800">{{ $failure['code'] ?? 'UNKNOWN' }}</div>
|
||||||
|
<div class="text-sm text-red-900">{{ $failure['message'] ?? 'Unknown error' }}</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white p-4 rounded shadow">
|
||||||
|
<h3 class="font-bold text-lg mb-2">Context</h3>
|
||||||
|
<pre class="bg-gray-100 p-2 rounded text-sm overflow-auto">{{ json_encode($run->context, JSON_PRETTY_PRINT) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\PruneOldOperationRunsJob;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@ -10,3 +11,8 @@
|
|||||||
|
|
||||||
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
||||||
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
|
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
|
||||||
|
|
||||||
|
Schedule::job(new PruneOldOperationRunsJob)
|
||||||
|
->daily()
|
||||||
|
->name(PruneOldOperationRunsJob::class)
|
||||||
|
->withoutOverlapping();
|
||||||
|
|||||||
@ -54,7 +54,7 @@ ### Decision: Default selection = “full inventory”
|
|||||||
|
|
||||||
### Decision: Attribute initiator on run record and audit trail
|
### Decision: Attribute initiator on run record and audit trail
|
||||||
- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record.
|
- **Chosen**: Store initiator identity on `InventorySyncRun` and also emit an audit record.
|
||||||
- **Rationale**: Improves traceability and aligns with constitution principle “Automation must be Idempotent & Observable”.
|
- **Rationale**: Improves traceability and aligns with constitution principle “Operations / Run Observability Standard”.
|
||||||
- **Alternatives considered**:
|
- **Alternatives considered**:
|
||||||
- Audit log only — rejected (you chose C).
|
- Audit log only — rejected (you chose C).
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ ### 3) Idempotency & de-duplication
|
|||||||
- Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal.
|
- Behavior: if an identical run is `queued`/`running`, reuse it and return/link to it; allow a new run only after terminal.
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
- Matches the constitution (“Automation must be Idempotent & Observable”) and aligns with existing patterns (inventory selection hash + schedule locks).
|
- Matches the constitution (“Operations / Run Observability Standard”) and aligns with existing patterns (inventory selection hash + schedule locks).
|
||||||
|
|
||||||
**Alternatives considered:**
|
**Alternatives considered:**
|
||||||
- **Cache-only locks** (`Cache::lock(...)`) without persisted keys.
|
- **Cache-only locks** (`Cache::lock(...)`) without persisted keys.
|
||||||
@ -75,4 +75,3 @@ ## Clarifications resolved
|
|||||||
|
|
||||||
- **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”.
|
- **SC-003 includes “canceled”** while Phase 1 explicitly has “no cancel”.
|
||||||
- Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting.
|
- Resolution for Phase 1 planning: treat “canceled” as out-of-scope (Phase 2+) and map “aborted” (if present) into the `failed` bucket for SC accounting.
|
||||||
|
|
||||||
|
|||||||
@ -80,7 +80,7 @@ ### 6) Idempotency & de-duplication
|
|||||||
- Race reduction: rely on the existing partial unique index for active runs and handle collisions by finding and reusing the existing run.
|
- Race reduction: rely on the existing partial unique index for active runs and handle collisions by finding and reusing the existing run.
|
||||||
|
|
||||||
**Rationale:**
|
**Rationale:**
|
||||||
- Aligns with the constitution (“Automation must be Idempotent & Observable”).
|
- Aligns with the constitution (“Operations / Run Observability Standard”).
|
||||||
- Durable across restarts and observable in the database.
|
- Durable across restarts and observable in the database.
|
||||||
|
|
||||||
**Alternatives considered:**
|
**Alternatives considered:**
|
||||||
|
|||||||
32
specs/054-unify-runs-suitewide/checklists/requirements.md
Normal file
32
specs/054-unify-runs-suitewide/checklists/requirements.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Requirements Checklist: Unified Operations Runs
|
||||||
|
|
||||||
|
## Phase 1 Adoption Set
|
||||||
|
- [x] `inventory.sync` (Inventory “Sync now”) covered in spec
|
||||||
|
- [x] `policy.sync` (Policies “Sync now”) covered in spec
|
||||||
|
- [x] `directory_groups.sync` (Directory → Groups “Sync groups”) covered in spec
|
||||||
|
- [x] `drift.generate` (Drift “Generate drift now”) covered in spec
|
||||||
|
- [x] `backup_set.add_policies` (Backup Sets “Add selected”) covered in spec
|
||||||
|
- [x] `backup_schedule.run_now` (Backup Schedules “Run now”) covered in implementation
|
||||||
|
- [x] `backup_schedule.retry` (Backup Schedules “Retry”) covered in implementation
|
||||||
|
- [x] `restore.execute` (adapter mode) covered in spec
|
||||||
|
|
||||||
|
## Critical Clarifications (Pinned)
|
||||||
|
- [x] Retention policy defined (90 days default)
|
||||||
|
- [x] Transition strategy defined (Parallel write: Canonical + Legacy)
|
||||||
|
- [x] Concurrency enforcement defined (Partial unique index on active runs)
|
||||||
|
- [x] Initiator model defined (Nullable FK + Name Snapshot)
|
||||||
|
- [x] Restore integration defined (Physical adapter row pointing to Restore Domain record)
|
||||||
|
|
||||||
|
## Functional Requirements (Spec Coverage)
|
||||||
|
- [x] FR-001 Canonical Operation Run schema defined (see `data-model.md`)
|
||||||
|
- [x] FR-004 Monitoring List UI specified (filters/sort defined in Spec FR-004)
|
||||||
|
- [x] FR-005 Monitoring Detail UI specified (content defined in Spec FR-005)
|
||||||
|
- [x] FR-007 Start surfaces behavior specified (Spec FR-007)
|
||||||
|
- [x] FR-009 Idempotency (Partial Unique Index) strategy defined (Spec FR-009, Plan)
|
||||||
|
- [x] FR-015 Notifications for queued/terminal states specified (Spec FR-015)
|
||||||
|
- [x] FR-016 Tenant isolation rules specified (Spec FR-016)
|
||||||
|
|
||||||
|
## Non-Functional (Spec Coverage)
|
||||||
|
- [x] SC-002 Start confirmation < 2s target defined (Spec SC-002)
|
||||||
|
- [x] SC-003 Deduplication rate > 99% strategy defined (Spec SC-003)
|
||||||
|
- [x] SC-004 No secrets in failure logs rule defined (Spec SC-004)
|
||||||
61
specs/054-unify-runs-suitewide/checklists/review.md
Normal file
61
specs/054-unify-runs-suitewide/checklists/review.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Spec Review Checklist: Unified Operations Runs Suitewide (054)
|
||||||
|
|
||||||
|
**Purpose**: Validate that `spec.md` is PR-reviewable and implementable by checking requirement quality (clarity, completeness, consistency, and testability), with emphasis on Audit-only vs OperationRun boundaries and tenant isolation/privacy/sanitization.
|
||||||
|
**Created**: 2026-01-17
|
||||||
|
**Feature**: `specs/054-unify-runs-suitewide/spec.md`
|
||||||
|
|
||||||
|
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] CHK001 Are Phase 1 adoption-set operations explicitly enumerated and scoped? [Completeness] Evidence: `spec.md` §Scope & Assumptions (“Phase 1 adoption set”)
|
||||||
|
- [x] CHK002 Are required Phase 1 run types explicitly listed and stable (including restore adapter and backup schedules)? [Completeness] Evidence: `spec.md` §FR-003
|
||||||
|
- [x] CHK003 Are mandatory run record fields specified (initiator, type, status/timestamps, outcome, counts, failures, identity, context)? [Completeness] Evidence: `spec.md` §FR-001
|
||||||
|
- [x] CHK004 Are lifecycle states and outcome buckets defined with allowed values? [Completeness] Evidence: `spec.md` §FR-011–FR-012
|
||||||
|
- [x] CHK005 Are idempotency identity rules specified for each Phase 1 run type (effective inputs included/excluded)? [Completeness] Evidence: `spec.md` §FR-010
|
||||||
|
- [x] CHK006 Are role/permission requirements defined for viewing runs vs starting operations? [Completeness] Evidence: `spec.md` §FR-018 + §User Story 1 (Scenario 5) + §User Story 2 (Scenario 2)
|
||||||
|
- [x] CHK007 Are notification requirements defined for queued and terminal outcomes (recipient, summary, “View run” link)? [Completeness] Evidence: `spec.md` §FR-015 + §User Story 2 (Scenario 3)
|
||||||
|
|
||||||
|
## Audit-only vs OperationRun Boundaries
|
||||||
|
|
||||||
|
- [x] CHK008 Is the eligibility criteria for Audit-only actions fully specified (DB-only, ≤2s, no remote calls, no background work)? [Clarity] Evidence: `spec.md` §FR-019
|
||||||
|
- [x] CHK009 Is it unambiguous when an action may remain Audit-only versus must be an OperationRun (e.g., operational relevance vs queued/remote)? [Clarity] Evidence: `spec.md` §FR-019 + §Rule (Run vs Audit-only)
|
||||||
|
- [x] CHK010 Are “security-relevant” / “operational behavior change” triggers for mandatory AuditLog entries defined beyond examples (so classification is reviewable)? [Clarity] Evidence: `spec.md` §FR-019 (“Trigger guidance”)
|
||||||
|
- [x] CHK011 Are required AuditLog fields complete and unambiguous (tenant, actor, stable action ID, target, before/after or diff, timestamp)? [Completeness] Evidence: `spec.md` §FR-019
|
||||||
|
- [x] CHK012 Does the spec define sanitization expectations for `before/after/diff` in AuditLog (what must be excluded) rather than assuming it? [Privacy] Evidence: `spec.md` §FR-019 (“Sanitization (AuditLog before/after/diff)”)
|
||||||
|
- [x] CHK013 Does the adoption matrix cover the required feature areas and assign a stable `run_type` or audit action id for each? [Completeness] Evidence: `spec.md` §Run vs Audit-only Adoption Matrix (Phase 1)
|
||||||
|
- [x] CHK014 Are the adoption matrix audit action identifiers consistent with the “stable action identifier” requirement for AuditLog entries? [Consistency] Evidence: `spec.md` §FR-019 + §Run vs Audit-only Adoption Matrix
|
||||||
|
- [x] CHK015 Are FR-019 acceptance checks sufficient and non-contradictory (no OperationRun; exactly one AuditLog; tenant-scoped; cross-tenant forbidden)? [Acceptance Criteria] Evidence: `spec.md` §FR-019 (Acceptance checks)
|
||||||
|
|
||||||
|
## Tenant Isolation & Privacy / Sanitization
|
||||||
|
|
||||||
|
- [x] CHK016 Are tenant isolation requirements explicitly stated for run list access and run detail access? [Completeness] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenarios 1, 6)
|
||||||
|
- [x] CHK017 Is “cross-tenant access is denied without disclosing run details” sufficiently specified to be reviewable (what must not be exposed)? [Clarity] Evidence: `spec.md` §FR-016 + §User Story 1 (Scenario 6)
|
||||||
|
- [x] CHK018 Are start-surface authorization requirements explicit enough to prevent unauthorized run creation (especially Readonly)? [Completeness] Evidence: `spec.md` §FR-007 + §FR-018 + §User Story 2 (Scenario 2)
|
||||||
|
- [x] CHK019 Are persisted failure requirements explicit about what MUST NOT be stored (tokens/credentials/PII/raw payload dumps)? [Clarity] Evidence: `spec.md` §FR-013 + §SC-004
|
||||||
|
- [x] CHK020 Are “stable reason codes” and “short sanitized messages” defined enough to be objectively reviewable (format expectations or examples)? [Clarity] Evidence: `spec.md` §FR-013 (Reason codes + Messages)
|
||||||
|
- [x] CHK021 Does the spec define what may be stored in run `context` and require it to be safe/sanitized (no secrets/PII)? [Privacy] Evidence: `spec.md` §FR-001 (“Context safety”)
|
||||||
|
- [x] CHK022 Are Monitoring render-time constraints explicit (DB-only; no external calls; no remote fetches at render time)? [Completeness] Evidence: `spec.md` §FR-017 + §FR-008
|
||||||
|
|
||||||
|
## Requirement Consistency
|
||||||
|
|
||||||
|
- [x] CHK023 Are the terms “status” and “outcome bucket” used consistently (and are queued/running treated consistently across the doc)? [Consistency] Evidence: `spec.md` §FR-001 (Status/Outcome semantics) + §FR-011 (Run state presentation)
|
||||||
|
- [x] CHK024 Are run type naming rules consistent across taxonomy, Phase 1 run list, and the adoption matrix (spelling/casing)? [Consistency] Evidence: `spec.md` §FR-002 + §FR-003 + §Run vs Audit-only Adoption Matrix
|
||||||
|
- [x] CHK025 Is restore adapter behavior described consistently across Clarifications, Scope (“Restore visibility”), FR-003, and Key Entities? [Consistency] Evidence: `spec.md` §Clarifications (restore) + §Scope & Assumptions (“Restore visibility”) + §FR-003 + §Key Entities
|
||||||
|
- [x] CHK026 Are retention and default monitoring window expectations consistent (retention vs default list time range)? [Consistency] Evidence: `spec.md` §Clarifications (retention) + §Assumptions + §FR-004
|
||||||
|
|
||||||
|
## Acceptance Criteria Quality
|
||||||
|
|
||||||
|
- [x] CHK027 Are success criteria measurable and objectively verifiable without implementation details? [Measurability] Evidence: `spec.md` §SC-001–SC-004
|
||||||
|
- [x] CHK028 Is the ≥99% dedupe target defined with a measurement scope (what counts as an attempt; “normal conditions” definition)? [Clarity] Evidence: `spec.md` §SC-003 (Measurement scope) + §FR-009
|
||||||
|
- [x] CHK029 Is “no secrets/PII” defined with an explicit boundary sufficient for reviewers to validate completeness? [Clarity] Evidence: `spec.md` §SC-004 + §FR-013
|
||||||
|
|
||||||
|
## Scenario Coverage
|
||||||
|
|
||||||
|
- [x] CHK030 Are primary, forbidden, and “background unavailable” scenarios covered with explicit, testable outcomes (including “must not claim queued”)? [Coverage] Evidence: `spec.md` §User Stories 1–3 + §User Story 2 (Scenario 4) + §Edge Cases
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Check items off as completed: `[x]`
|
||||||
|
- Add findings inline (e.g., under a checklist item) with links to the relevant spec section
|
||||||
|
- This checklist evaluates requirements quality, not implementation correctness
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot Admin Operations Contracts (Feature 054)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Minimal page-render contracts for the Monitoring/Operations hub.
|
||||||
|
|
||||||
|
These pages must render from the database only (no external tenant calls)
|
||||||
|
and display only sanitized failure detail (no secrets/tokens/raw payload dumps).
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/admin/t/{tenantExternalId}/operations:
|
||||||
|
get:
|
||||||
|
operationId: monitoringOperationsIndex
|
||||||
|
summary: Monitoring → Operations (tenant-scoped)
|
||||||
|
parameters:
|
||||||
|
- name: tenantExternalId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Page renders successfully.
|
||||||
|
'302':
|
||||||
|
description: Redirect to login when unauthenticated.
|
||||||
|
|
||||||
|
/admin/t/{tenantExternalId}/operations/{operationRunId}:
|
||||||
|
get:
|
||||||
|
operationId: monitoringOperationsView
|
||||||
|
summary: Operation run detail (tenant-scoped)
|
||||||
|
parameters:
|
||||||
|
- name: tenantExternalId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: operationRunId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Page renders successfully.
|
||||||
|
'302':
|
||||||
|
description: Redirect to login when unauthenticated.
|
||||||
|
'403':
|
||||||
|
description: Forbidden when attempting cross-tenant access.
|
||||||
|
|
||||||
|
components: {}
|
||||||
14
specs/054-unify-runs-suitewide/contracts/routes.md
Normal file
14
specs/054-unify-runs-suitewide/contracts/routes.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Routes & URLs
|
||||||
|
|
||||||
|
## Monitoring UI
|
||||||
|
|
||||||
|
### List Operations
|
||||||
|
- **URL**: `/admin/t/{tenantExternalId}/operations`
|
||||||
|
- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (index)
|
||||||
|
|
||||||
|
### View Operation
|
||||||
|
- **URL**: `/admin/t/{tenantExternalId}/operations/{operationRunId}`
|
||||||
|
- **Surface**: Filament Resource `App\Filament\Resources\OperationRunResource` (view)
|
||||||
|
|
||||||
|
## Deep Links
|
||||||
|
- Use Filament URL helpers (`Resource::getUrl(...)`, `Page::getUrl(...)`) to generate tenant-scoped links back to owning feature surfaces/results.
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
# Service Interface: Operation Runs
|
||||||
|
|
||||||
|
## `App\Services\OperationRunService`
|
||||||
|
|
||||||
|
### `ensureRun`
|
||||||
|
Idempotently creates or retrieves an active run.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function ensureRun(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $type,
|
||||||
|
array $inputs,
|
||||||
|
?User $initiator = null
|
||||||
|
): OperationRun
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Logic**:
|
||||||
|
1. Compute `hash = sha256(tenant_id + type + sorted_json(inputs))`.
|
||||||
|
2. Try finding active run (`queued` or `running`) with this hash.
|
||||||
|
3. If found, return it.
|
||||||
|
4. If not found, create new `queued` run.
|
||||||
|
5. Return run.
|
||||||
|
6. If an existing active run is returned (dedupe), the initiator (`user_id`, `initiator_name`) MUST NOT be replaced.
|
||||||
|
|
||||||
|
- **Dispatch failure**:
|
||||||
|
- If queue dispatch fails after a run was created, the system MUST NOT leave misleading queued runs; instead complete the run immediately as `failed` (e.g., failure code `queue.dispatch_failed`) and show a clear UI message.
|
||||||
|
|
||||||
|
### `updateRun`
|
||||||
|
Updates the status/outcome of a run.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function updateRun(
|
||||||
|
OperationRun $run,
|
||||||
|
string $status,
|
||||||
|
?string $outcome = null,
|
||||||
|
array $summaryCounts = [],
|
||||||
|
array $failures = []
|
||||||
|
): OperationRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### `failRun`
|
||||||
|
Helper to fail a run immediately.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||||
|
```
|
||||||
|
|
||||||
|
## `App\Jobs\Middleware\TrackOperationRun`
|
||||||
|
Middleware for Jobs to automatically handle `running` -> `completed`/`failed` transitions if bound to a run.
|
||||||
|
|
||||||
|
## `App\Listeners\SyncRestoreRunToOperationRun`
|
||||||
|
Listener for `RestoreRun` events to update the shadow `OperationRun`.
|
||||||
|
The adapter row is created/visible only once a restore run reaches `previewed` (or later).
|
||||||
73
specs/054-unify-runs-suitewide/data-model.md
Normal file
73
specs/054-unify-runs-suitewide/data-model.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Data Model: Unified Operations Runs
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### `OperationRun`
|
||||||
|
Canonical record for all long-running tenant operations.
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `id` | BigInt | Yes | Primary Key |
|
||||||
|
| `tenant_id` | BigInt | Yes | FK to Tenants |
|
||||||
|
| `user_id` | BigInt | No | FK to Users (Initiator). Null for system/scheduler. |
|
||||||
|
| `initiator_name` | String | Yes | Snapshot of user name or "System". |
|
||||||
|
| `type` | String | Yes | stable taxonomy e.g., `inventory.sync`. |
|
||||||
|
| `status` | String | Yes | Lifecycle state: `queued`, `running`, `completed`. |
|
||||||
|
| `outcome` | String | Yes | Result bucket: `pending`, `succeeded`, `partially_succeeded`, `failed`, `cancelled`. |
|
||||||
|
| `run_identity_hash` | String | Yes | Deterministic hash for idempotency. |
|
||||||
|
| `summary_counts` | JSONB | No | `{ "total": 10, "success": 8, "failed": 2, "skipped": 0 }` |
|
||||||
|
| `failure_summary` | JSONB | No | List of sanitized errors: `[{ "code": "graph.throttled", "message": "Throttled (retrying)", "count": 1 }]` |
|
||||||
|
| `context` | JSONB | No | Run-specific metadata. e.g., `{ "restore_run_id": 123, "selection": [...] }` |
|
||||||
|
| `started_at` | Timestamp | No | When execution began. |
|
||||||
|
| `completed_at` | Timestamp | No | When execution finished. |
|
||||||
|
| `created_at` | Timestamp | Yes | |
|
||||||
|
| `updated_at` | Timestamp | Yes | |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `(tenant_id, run_identity_hash)` UNIQUE WHERE status IN ('queued', 'running')
|
||||||
|
- `(tenant_id, type, created_at)` for filtering/sorting
|
||||||
|
- `(tenant_id, created_at)` for default sort
|
||||||
|
|
||||||
|
### `RestoreRun` (Existing)
|
||||||
|
Remains the domain source of truth for Restore.
|
||||||
|
- Linked via `OperationRun.context['restore_run_id']`.
|
||||||
|
- Adapter row is created/visible only once `RestoreRunStatus=previewed` (or later).
|
||||||
|
- When `RestoreRunStatus=previewed`, the adapter uses `OperationRun.status=queued` and `OperationRun.outcome=pending`.
|
||||||
|
- `OperationRun` mirrors the restore execution lifecycle for Monitoring visibility (restore domain history remains owned by `RestoreRun`).
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
### `OperationRunStatus`
|
||||||
|
- `queued`
|
||||||
|
- `running`
|
||||||
|
- `completed`
|
||||||
|
|
||||||
|
### `OperationRunOutcome`
|
||||||
|
- `pending` (default when running/queued)
|
||||||
|
- `succeeded`
|
||||||
|
- `partially_succeeded`
|
||||||
|
- `failed`
|
||||||
|
- `cancelled` (reserved/future; MUST NOT be produced by 054)
|
||||||
|
|
||||||
|
**UI label mapping** (display-only):
|
||||||
|
|
||||||
|
- `pending` → “Pending”
|
||||||
|
- `succeeded` → “Succeeded”
|
||||||
|
- `partially_succeeded` → “Partially succeeded”
|
||||||
|
- `failed` → “Failed”
|
||||||
|
- `cancelled` → “Cancelled” (reserved)
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
`OperationRun.status` transitions:
|
||||||
|
|
||||||
|
- `queued` → `running` → `completed`
|
||||||
|
|
||||||
|
`OperationRun.outcome` transitions:
|
||||||
|
|
||||||
|
- `pending` while `status` is `queued` or `running`
|
||||||
|
- one of `succeeded`, `partially_succeeded`, `failed`, `cancelled` when `status` is `completed`
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
- `OperationRun` belongs to `Tenant`.
|
||||||
|
- `OperationRun` belongs to `User` (optional).
|
||||||
98
specs/054-unify-runs-suitewide/plan.md
Normal file
98
specs/054-unify-runs-suitewide/plan.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# Implementation Plan: Unified Operations Runs Suitewide (054)
|
||||||
|
|
||||||
|
**Branch**: `feat/054-unify-operations-runs-suitewide` | **Date**: 2026-01-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md` ([spec.md](spec.md))
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Eliminate “run sprawl” by adopting a single tenant-scoped canonical run record (`operation_runs`) for long-running and
|
||||||
|
operationally relevant actions across the product, surfaced consistently in Monitoring → Operations (list + detail).
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- Start surfaces are enqueue-only: authorize → create/reuse canonical run (dedupe) → dispatch → confirm + “View run”.
|
||||||
|
- Legacy per-module run tables remain in parallel where they exist; Monitoring/Operations uses canonical runs.
|
||||||
|
- Restore remains a domain workflow record, but is mirrored into canonical runs via an adapter row (`restore.execute`)
|
||||||
|
created from `RestoreRunStatus=previewed` onward (`status=queued`, `outcome=pending` until execution begins).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Filament v4, Livewire v3
|
||||||
|
**Storage**: PostgreSQL (`operation_runs` + JSONB for summary/failures/context; partial unique index for active-run dedupe)
|
||||||
|
**Testing**: Pest v4 (PHPUnit v12)
|
||||||
|
**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy)
|
||||||
|
**Project Type**: web
|
||||||
|
**Performance Goals**: Start surfaces confirm within 2 seconds and provide a “View run” link; Monitoring/Operations list is usable with default last-30-days window and filters.
|
||||||
|
**Constraints**: Tenant isolation is non-negotiable; Monitoring is DB-only at render time; no remote work inline; failures are stable codes + sanitized messages (no secrets/tokens/raw payload dumps).
|
||||||
|
**Scale/Scope**: Tenant-scoped run history across modules; retention defaults to 90 days.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: PASS (Monitoring uses persisted run records; does not redefine Inventory semantics).
|
||||||
|
- Read/write separation: PASS (Monitoring/Operations is view-only; writes remain in their owning UIs with explicit confirmation/audit where applicable).
|
||||||
|
- Graph contract path: PASS (Monitoring makes no Graph calls; start surfaces MUST NOT perform remote work inline).
|
||||||
|
- Deterministic capabilities: N/A (no new capability resolver introduced in this feature).
|
||||||
|
- Tenant isolation: PASS (all run access is tenant-scoped; cross-tenant access is forbidden).
|
||||||
|
- Run observability: PASS (queued/remote/scheduled work is tracked as canonical runs and links to a single Monitoring hub).
|
||||||
|
- Automation: PASS (active-run de-duplication via deterministic identity + partial unique index).
|
||||||
|
- Data minimization: PASS (failure summaries are sanitized and stable; no secrets/tokens/raw payload dumps in persisted failures/notifications).
|
||||||
|
|
||||||
|
**Gate status (pre-Phase 0)**: PASS (no violations).
|
||||||
|
**Gate status (post-Phase 1)**: PASS (design artifacts present: `research.md`, `data-model.md`, `contracts/`, `quickstart.md`).
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── spec.md # Feature specification (input)
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md # Spec quality checklist
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ └── Resources/
|
||||||
|
├── Jobs/
|
||||||
|
│ └── Middleware/
|
||||||
|
├── Listeners/
|
||||||
|
├── Models/
|
||||||
|
├── Notifications/
|
||||||
|
├── Observers/
|
||||||
|
├── Policies/
|
||||||
|
├── Services/
|
||||||
|
└── Support/
|
||||||
|
|
||||||
|
database/
|
||||||
|
└── migrations/
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── console.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
└── Unit/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel web application. Implement canonical runs via Eloquent (`OperationRun`) + a small service layer for idempotent creation and lifecycle updates, instrument background jobs via middleware, and surface runs in Filament Monitoring/Operations (tenant-scoped, view-only).
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| None | | |
|
||||||
51
specs/054-unify-runs-suitewide/quickstart.md
Normal file
51
specs/054-unify-runs-suitewide/quickstart.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Quickstart: Adding a New Operation
|
||||||
|
|
||||||
|
## 1. Register Run Type
|
||||||
|
Add your new type constant to `App\Support\OperationRunType` (if using Enums) or just use the string convention `resource.action`.
|
||||||
|
|
||||||
|
## 2. Implement Idempotency Inputs
|
||||||
|
Define what makes a run "unique" for your feature.
|
||||||
|
- Example: `['scope' => 'full']` vs `['scope' => 'policy', 'policy_id' => 1]`.
|
||||||
|
|
||||||
|
## 3. Use `OperationRunService`
|
||||||
|
In your Start Action (Controller/Livewire):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. Ensure Run
|
||||||
|
$run = $service->ensureRun($tenant, 'my_resource.action', $inputs, auth()->user());
|
||||||
|
|
||||||
|
// 2. Dispatch Job (if new)
|
||||||
|
if ($run->wasRecentlyCreated) {
|
||||||
|
$service->dispatchOrFail($run, function () use ($run, $inputs): void {
|
||||||
|
MyJob::dispatch($run, $inputs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Return View Link
|
||||||
|
return redirect(\App\Support\OperationRunLinks::viewUrl($tenant, $run));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Instrument Job
|
||||||
|
In your Job:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function handle(\App\Services\OperationRunService $service)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// ... do work ...
|
||||||
|
|
||||||
|
// Success
|
||||||
|
$service->updateRun($this->run, status: 'completed', outcome: 'succeeded', summaryCounts: [
|
||||||
|
'processed' => 100,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Failure
|
||||||
|
$service->failRun($this->run, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes: Monitoring & Run-Standard (Graph safety)
|
||||||
|
|
||||||
|
- **RBAC Wizard** (`TenantResource`): group search is **delegated-Graph-based** and the picker is **disabled without a delegated token**.
|
||||||
|
- **Restore Wizard** (`RestoreRunResource`): group mapping stays **DB-only** (Directory Cache / `entra_groups`) during the mapping phase — **no Graph calls** there; fallback helper text is always visible.
|
||||||
77
specs/054-unify-runs-suitewide/research.md
Normal file
77
specs/054-unify-runs-suitewide/research.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Research: Unified Operations Runs Suitewide
|
||||||
|
|
||||||
|
## 1. Technical Context & Unknowns
|
||||||
|
|
||||||
|
**Unknowns Resolved**:
|
||||||
|
- **Transition Strategy**: Parallel write. We will maintain existing legacy tables (e.g., `inventory_sync_runs`, `restore_runs`) for now but strictly use `operation_runs` for the Monitoring UI.
|
||||||
|
- **Restore Adapter**: `RestoreRun` remains the domain source of truth. An `OperationRun` adapter row will be created once a restore run reaches `previewed` (and later statuses), and will be kept in sync via `RestoreRun` lifecycle events or service-layer wrapping.
|
||||||
|
- **Run Logic Location**: Existing jobs like `RunInventorySyncJob` will be updated to manage the `OperationRun` state.
|
||||||
|
- **Concurrency**: Enforced by partial unique index on `(tenant_id, run_identity_hash)` where status is active (`queued`, `running`).
|
||||||
|
- **Dispatch Failure Semantics**: If queue dispatch fails, the system will immediately complete the run as `failed` (e.g., `queue.dispatch_failed`) and show a clear UI message (never leaving misleading queued runs).
|
||||||
|
- **Notifications on Dedupe**: Only the original initiator (`operation_runs.user_id`) receives queued/terminal notifications; reusers of an active run do not get additional notifications.
|
||||||
|
|
||||||
|
## 2. Technology Choices
|
||||||
|
|
||||||
|
| Area | Decision | Rationale | Alternatives |
|
||||||
|
|------|----------|-----------|--------------|
|
||||||
|
| **Schema** | `operation_runs` table | Centralized table allows simple, performant Monitoring queries without complex UNIONs across disparate legacy tables. | Virtual UNION view (Complex, harder to paginate/sort efficiently). |
|
||||||
|
| **Restore Integration** | Physical Adapter Row | Decouples Monitoring from Restore domain specifics. Allows uniform "list all runs" queries. The `context` JSON column will store `{ "restore_run_id": ... }`. | Polymorphic relation (Overhead for a single exception). |
|
||||||
|
| **Idempotency** | DB Partial Unique Index | Hard guarantee against race conditions. Simpler than distributed locks (Redis) which can expire or fail. | Redis Lock (Soft guarantee), Application check (Race prone). |
|
||||||
|
| **Initiator** | Nullable FK + Name | Handles both Users (FK) and System/Scheduler (Name "System") uniformly. | Polymorphic relation (Overkill for simple auditing). |
|
||||||
|
|
||||||
|
## 3. Implementation Patterns
|
||||||
|
|
||||||
|
### Canonical Run Lifecycle
|
||||||
|
1. **Start Request**:
|
||||||
|
- Compute `run_identity_hash` from inputs.
|
||||||
|
- Attempt `INSERT` into `operation_runs` (idempotent; enforced by partial unique index for active runs).
|
||||||
|
- If an active run exists, return it (Idempotency).
|
||||||
|
- If new, dispatch the background Job.
|
||||||
|
- If dispatch fails, immediately mark the run `status=completed`, `outcome=failed` with a safe failure code such as `queue.dispatch_failed`.
|
||||||
|
2. **Job Execution**:
|
||||||
|
- Update status to `running`.
|
||||||
|
- Perform work.
|
||||||
|
- Update status to `completed` and set terminal outcome (`succeeded` / `partially_succeeded` / `failed` / `cancelled`).
|
||||||
|
3. **Restore Adapter**:
|
||||||
|
- Create the adapter row only once `RestoreRunStatus=previewed` (or later) is reached.
|
||||||
|
- Map `RestoreRunStatus=previewed` to `OperationRun.status=queued` and `OperationRun.outcome=pending`.
|
||||||
|
- Keep the adapter updated as the restore progresses:
|
||||||
|
- `queued` → `status=queued`, `outcome=pending`
|
||||||
|
- `running` → `status=running`, `outcome=pending`
|
||||||
|
- `completed` → `status=completed`, `outcome=succeeded`
|
||||||
|
- `partial` → `status=completed`, `outcome=partially_succeeded`
|
||||||
|
- `failed` → `status=completed`, `outcome=failed`
|
||||||
|
- `cancelled` → `status=completed`, `outcome=cancelled`
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
```sql
|
||||||
|
CREATE TABLE operation_runs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||||
|
user_id BIGINT NULL REFERENCES users(id), -- Initiator
|
||||||
|
initiator_name VARCHAR(255) NOT NULL, -- "John Doe" or "System"
|
||||||
|
type VARCHAR(255) NOT NULL, -- "inventory.sync"
|
||||||
|
status VARCHAR(50) NOT NULL, -- queued, running, completed
|
||||||
|
outcome VARCHAR(50) NOT NULL, -- pending, succeeded, partially_succeeded, failed, cancelled
|
||||||
|
run_identity_hash VARCHAR(64) NOT NULL, -- SHA256(tenant_id + inputs)
|
||||||
|
summary_counts JSONB DEFAULT '{}', -- { success: 10, failed: 2 }
|
||||||
|
failure_summary JSONB DEFAULT '[]', -- [{ code: "ERR_TIMEOUT", message: "..." }]
|
||||||
|
context JSONB DEFAULT '{}', -- { selection: [...], restore_run_id: 123 }
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX operation_runs_active_unique
|
||||||
|
ON operation_runs (tenant_id, run_identity_hash)
|
||||||
|
WHERE status IN ('queued', 'running');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Risks & Mitigations
|
||||||
|
- **Risk**: Desync between `RestoreRun` and `OperationRun`.
|
||||||
|
- **Mitigation**: Use model observers or service-layer wrapping to ensure atomic-like updates, or accept slight eventual consistency (Monitoring might lag ms behind Restore UI).
|
||||||
|
- **Risk**: Legacy runs not appearing.
|
||||||
|
- **Mitigation**: We are NOT backfilling legacy runs. Only new runs after deployment will appear in the new Monitoring UI. This is acceptable for "Phase 1".
|
||||||
|
- **Risk**: Confusion about `queued` for restore `previewed`.
|
||||||
|
- **Mitigation**: Document that `restore.execute` appears from `previewed` onward and uses `queued/pending` until execution begins; Monitoring remains view-only and links to the restore domain detail.
|
||||||
234
specs/054-unify-runs-suitewide/spec.md
Normal file
234
specs/054-unify-runs-suitewide/spec.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# Feature Specification: Unified Operations Runs Suitewide (Except Restore Domain Model) (054)
|
||||||
|
|
||||||
|
**Feature Branch**: `feat/054-unify-operations-runs-suitewide`
|
||||||
|
**Created**: 2026-01-16
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Eliminate run sprawl by adopting one canonical tenant-scoped operation run record for long-running actions across the product, surfaced consistently in Monitoring → Operations, while keeping restore as a separate domain workflow that is still visible via an adapter entry."
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-16
|
||||||
|
|
||||||
|
- Q: Welche Default-Retention soll 054 für canonical Operation Runs festlegen? → A: 90 days
|
||||||
|
- Q: Transition-Strategie in 054: schreiben wir canonical Runs parallel zu Legacy-Run-Tabellen, oder ersetzen wir sofort? → A: Parallel write (canonical + legacy)
|
||||||
|
- Q: For `restore.execute`, the spec mentions it acts as an "adapter entry" linking to the restore domain record. How should this be implemented? → A: Physical Row (Create a physical row in `operation_runs` that points to the restore record).
|
||||||
|
- Q: How should concurrency and deduplication (FR-009) be enforced at the database level? → A: Partial Unique Index (unique constraint on `tenant_id, run_identity_hash` where status is `queued` or `running`).
|
||||||
|
- Q: How should the `initiator` be modeled to support both users and system processes (FR-001)? → A: Nullable FK + Name Snapshot (`user_id` nullable FK + required `initiator_name` string).
|
||||||
|
|
||||||
|
### Session 2026-01-17
|
||||||
|
|
||||||
|
- Q: Sollen `backup_schedule.run_now` und `backup_schedule.retry` in 054 zur Phase-1-Adoption (must be implemented) gehören? → A: Yes — both are Phase 1 in 054 (OperationRun producers + worker tracking).
|
||||||
|
- Q: Wenn Queue-Dispatch fehlschlägt (Background Processing unavailable), sollen wir trotzdem einen `OperationRun` anlegen und ihn sofort als fehlgeschlagen abschließen? → A: Yes — create an `OperationRun` and immediately complete it as `failed` (e.g., failure code `queue.dispatch_failed`); show a clear error and MAY include a “View run” link.
|
||||||
|
- Q: Wenn ein Start deduped wird (Run wird wiederverwendet), wer soll die In‑App Notifications (“queued” + terminal outcome) bekommen? → A: Only the original initiator (`operation_runs.user_id`); no additional notifications are sent to the second starter on reuse.
|
||||||
|
- Q: Für `restore.execute`: In welchen `RestoreRunStatus`-Phasen soll überhaupt ein `OperationRun`-Adapter‑Row erzeugt/angezeigt werden? → A: From `previewed` onwards (previewed + execution statuses); no adapter row for `draft`/`scoped`/`checked`.
|
||||||
|
- Q: Wenn der `restore.execute` Adapter bereits ab `RestoreRunStatus=previewed` sichtbar ist: welchen `OperationRun`-State sollen wir für diese Phase setzen? → A: `status=queued`, `outcome=pending` (until `running`, then `completed` + terminal outcome).
|
||||||
|
- Q: RBAC Wizard (`TenantResource`) – wie funktioniert Group Search? → A: Group search is delegated-Graph-based and the picker MUST be disabled without delegated auth.
|
||||||
|
- Q: Restore Wizard (`RestoreRunResource`) – Group Mapping Phase: Graph oder DB-only? → A: DB-only via Directory Cache (`entra_groups`), no Graph calls during mapping; helper text is always shown (fallback included).
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I want Monitoring → Operations to show all supported long-running operations for my tenant in one consistent list and detail view, so I can quickly answer what ran, who started it, whether it succeeded/partially succeeded/failed, and where to look next.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core value: a single, tenant-scoped source of truth for operational visibility.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger at least one run of each Phase 1 run producer, then verify each appears in Monitoring with consistent status/outcome semantics, safe failure summaries, and context links.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I am signed into tenant A, **When** I open Monitoring → Operations, **Then** I see only tenant A runs and can filter by run type, run state (queued/running/terminal outcome), time range, and initiator.
|
||||||
|
2. **Given** multiple run types exist, **When** I filter to `inventory.sync`, **Then** only inventory sync runs are shown.
|
||||||
|
3. **Given** a run exists, **When** I open its detail view, **Then** I can see initiator, run type, run state (queued/running/terminal outcome), timestamps, summary counts (if applicable), sanitized failures (if any), and links to relevant feature context/results.
|
||||||
|
4. **Given** a restore run has reached `previewed` or later, **When** I open Monitoring → Operations, **Then** I can see a `restore.execute` entry that links to the existing restore record (restore history remains owned by the restore domain record).
|
||||||
|
5. **Given** I am a `Readonly` user in tenant A, **When** I view Monitoring → Operations, **Then** I can view runs and details but I do not see any start/rerun/cancel/delete controls.
|
||||||
|
6. **Given** I attempt to access a run from another tenant (direct link or list), **When** I request it, **Then** access is denied and no run details are disclosed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Start Operations Without Blocking (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, when I start a supported operation, I want immediate confirmation and a “View run” link so I can continue working while the operation runs in the background.
|
||||||
|
|
||||||
|
**Why this priority**: Removes long-running requests/timeouts and standardizes how operations are started and observed.
|
||||||
|
|
||||||
|
**Independent Test**: Start each Phase 1 operation from its owning UI and confirm the start returns quickly, includes “View run”, and the run progresses through queued/running into a terminal outcome.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I have permission to start a Phase 1 operation in tenant A, **When** I start it, **Then** I receive immediate confirmation with a “View run” link and the run is visible as queued or running.
|
||||||
|
2. **Given** I am a `Readonly` user in tenant A, **When** I attempt to start any Phase 1 operation, **Then** the system denies the request and does not create a new run.
|
||||||
|
3. **Given** the run reaches a terminal outcome, **When** that occurs, **Then** the initiating user receives an in-app notification including a short summary and a “View run” link.
|
||||||
|
4. **Given** background processing is unavailable, **When** I attempt to start an operation, **Then** I receive a clear message and the system MUST NOT claim it was queued.
|
||||||
|
- If an `OperationRun` record was created during the attempt, it MUST be completed immediately with outcome `failed` (never left `queued`) and MAY be linked via “View run”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
|
||||||
|
|
||||||
|
As an operator, I want accidental double-starts (double clicks, two admins, retries) to reuse the same active run so duplicate background work is avoided and results remain auditable.
|
||||||
|
|
||||||
|
**Why this priority**: Reduces load, prevents confusing duplicate outcomes, and makes operations safer under concurrency.
|
||||||
|
|
||||||
|
**Independent Test**: Start the same operation twice with identical effective inputs while the first is queued/running and verify the system reuses the active run.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an identical run is queued/running for a tenant, **When** another start request is made with the same effective inputs, **Then** the system reuses the existing run and does not start a second one.
|
||||||
|
2. **Given** two starts happen at nearly the same time, **When** the system resolves the race, **Then** at most one active run exists for that identity and both users are directed to it.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Background execution unavailable: start fails fast with a clear message; if an `OperationRun` record was created, it MUST be immediately completed as `failed` (e.g., `queue.dispatch_failed`) and MUST NOT be left `queued`.
|
||||||
|
- Partial processing: at least one success and at least one failure yields “partially succeeded”, with per-item failures when applicable.
|
||||||
|
- Large run history: Monitoring remains usable with filters and defaults (recent runs, last 30 days).
|
||||||
|
- Permissions revoked mid-run: the run continues; visibility is evaluated at time of access.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any external tenant API calls or any write/change behavior,
|
||||||
|
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
|
||||||
|
|
||||||
|
### Scope & Assumptions
|
||||||
|
|
||||||
|
**Phase 1 adoption set (must be implemented):**
|
||||||
|
|
||||||
|
- `inventory.sync` (Inventory “Sync now”)
|
||||||
|
- `policy.sync` (Policies “Sync now”)
|
||||||
|
- `directory_groups.sync` (Directory → Groups “Sync groups”)
|
||||||
|
- `drift.generate` (Drift “Generate drift now” / auto-on-open when eligible)
|
||||||
|
- `backup_set.add_policies` (Backup Sets “Add selected” / “Add policies”)
|
||||||
|
- `backup_schedule.run_now` (Backup Schedules “Run now”)
|
||||||
|
- `backup_schedule.retry` (Backup Schedules “Retry”)
|
||||||
|
|
||||||
|
**Restore visibility (adapter only):**
|
||||||
|
|
||||||
|
- `restore.execute` appears as a canonical run entry that links to an existing restore domain record.
|
||||||
|
- The adapter row MUST be created/visible only once a restore run reaches `previewed` (or later) and MUST NOT be created for `draft`, `scoped`, or `checked`.
|
||||||
|
- When the restore run is `previewed`, the adapter `OperationRun` MUST use `status=queued` and `outcome=pending`.
|
||||||
|
- Restore execution history remains owned by the restore domain record (not replaced in Phase 1).
|
||||||
|
|
||||||
|
**Out of scope for 054 (explicit):**
|
||||||
|
|
||||||
|
- Cross-tenant compare/promotion
|
||||||
|
- UI redesign/styling polish (separate UI polish work)
|
||||||
|
- Cancel/rerun/delete controls inside Monitoring hub (hub stays view-only)
|
||||||
|
- Replacing restore domain records with canonical runs
|
||||||
|
- A full settings UI for retention/notifications/etc.
|
||||||
|
- Implementing or validating `AuditLog` behavior for audit-only actions (FR-019) beyond actions explicitly changed by 054
|
||||||
|
|
||||||
|
**Assumptions (defaults to remove ambiguity in Phase 1):**
|
||||||
|
|
||||||
|
- Canonical run history retention defaults to 90 days, with no user-facing retention configuration in 054.
|
||||||
|
- System-initiated runs (if any) do not notify users by default in Phase 1.
|
||||||
|
- Transition strategy: write canonical runs in parallel with any existing legacy per-module run tables (where they exist); Monitoring uses canonical runs as the source of truth immediately.
|
||||||
|
|
||||||
|
**Run vs Audit-only Adoption Matrix (Phase 1):**
|
||||||
|
|
||||||
|
| Feature Area | Action | Tracking | run_type / audit action |
|
||||||
|
|-------------|--------|----------|--------------------------|
|
||||||
|
| Policies | Sync now | OperationRun | `policy.sync` |
|
||||||
|
| Policies | Ignore policy | Audit-only | `policy.ignore` |
|
||||||
|
| Policies | Export to backup | OperationRun (queued) | `policy.export_backup` |
|
||||||
|
| Policy Versions | Capture snapshot | OperationRun | `policy.capture_snapshot` |
|
||||||
|
| Policy Versions | Prune versions | Audit-only | `policy_versions.prune` |
|
||||||
|
| Policy Versions | Archive versions | Audit-only | `policy_versions.archive` |
|
||||||
|
| Inventory | Sync now | OperationRun | `inventory.sync` |
|
||||||
|
| Directory Groups | Sync groups | OperationRun | `directory_groups.sync` |
|
||||||
|
| Drift | Generate drift | OperationRun | `drift.generate` |
|
||||||
|
| Backup Sets | Add policies | OperationRun | `backup_set.add_policies` |
|
||||||
|
| Backup Sets | Archive | Audit-only (DB-only) | `backup_set.archive` |
|
||||||
|
| Backup Sets | Restore (bulk) | OperationRun | `backup_set.restore` |
|
||||||
|
| Backup Sets | Force delete | Audit-only (admin-only) | `backup_set.force_delete` |
|
||||||
|
| Backup Schedules | Run now | OperationRun | `backup_schedule.run_now` |
|
||||||
|
| Backup Schedules | Retry | OperationRun | `backup_schedule.retry` |
|
||||||
|
| Backup Schedules | Edit | Audit-only | `backup_schedule.edit` |
|
||||||
|
| Backup Schedules | Delete | Audit-only | `backup_schedule.delete` |
|
||||||
|
| Tenants | Sync tenant | OperationRun | `tenant.sync` |
|
||||||
|
| Tenants | Admin consent | Audit-only | `tenant.admin_consent` |
|
||||||
|
| Tenants | Verify configuration | Audit-only | `tenant.verify_config` |
|
||||||
|
| Tenants | Setup Intune RBAC | Audit-only | `tenant.setup_rbac` |
|
||||||
|
| Tenants | Deactivate | Audit-only | `tenant.deactivate` |
|
||||||
|
| Restore | Execute restore | OperationRun (adapter) | `restore.execute` (context → `restore_run_id`) |
|
||||||
|
|
||||||
|
**Rule**: If an action is queued/background, long-running, or requires remote/external calls (e.g., Microsoft Graph),
|
||||||
|
it MUST be tracked as an OperationRun. Only fast DB-only changes MAY be Audit-only.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 Canonical Operation Run**: System MUST represent each supported operation execution as a canonical, tenant-scoped operation run record that captures initiator (nullable `user_id` FK + `initiator_name` string), run type, lifecycle status/timestamps, terminal outcome (pending while active), summary counts (when applicable), safe failure summaries, an idempotency identity for dedupe, and a safe context payload referencing “what this run was about”.
|
||||||
|
- **Status semantics**: `status` represents lifecycle stage (`queued` → `running` → `completed`).
|
||||||
|
- **Outcome semantics (stored tokens)**: `outcome` stores machine tokens: `pending` while active, otherwise `succeeded` / `partially_succeeded` / `failed`.
|
||||||
|
- **UI labels**: Monitoring displays human labels derived from stored tokens (e.g., `partially_succeeded` → “Partially succeeded”).
|
||||||
|
- **Reserved**: `cancelled` is reserved for future use and MUST NOT be produced by 054 (Monitoring hub has no cancel controls).
|
||||||
|
- **Context safety**: `context` MUST be sanitized and MUST include only safe references (e.g., stable IDs, selection scope keys, correlation IDs). It MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
|
||||||
|
- **FR-002 Run taxonomy**: Run type MUST be stable and follow `"<resource>.<action>"`.
|
||||||
|
- **FR-003 Phase 1 run types**: Phase 1 run types MUST include `inventory.sync`, `policy.sync`, `directory_groups.sync`, `drift.generate`, `backup_set.add_policies`, `backup_schedule.run_now`, `backup_schedule.retry`, plus `restore.execute` implemented as a physical `operation_runs` record (adapter) pointing to the domain entity.
|
||||||
|
- **FR-004 Monitoring lists all canonical runs**: Monitoring → Operations MUST list canonical runs for the active tenant with filters for run type, run state (queued/running/terminal outcome), time range, and initiator; default sort is most recent first; default time window is last 30 days.
|
||||||
|
- **FR-005 Run detail**: Run detail MUST show initiator, run type, run state (queued/running/terminal outcome), timestamps (created/started/finished), summary counts (when applicable), sanitized failures (including per-item failures when applicable), and contextual links to owning feature surfaces/results.
|
||||||
|
- **FR-006 View-only hub**: Monitoring hub MUST be view-only (no start/rerun/cancel/delete controls) and MUST link back to owning feature surfaces.
|
||||||
|
- **FR-007 Start surfaces always enqueue**: Every Phase 1 start surface MUST authorize start, create/reuse a canonical run (dedupe), dispatch background execution, and return immediately with confirmation + “View run”.
|
||||||
|
- **FR-008 No remote work in interactive request**: Start surfaces MUST NOT perform remote work inline; long-running work happens in background execution.
|
||||||
|
- **FR-009 Deterministic idempotency**: For each run type, the system MUST define a deterministic identity for “identical run” based on tenant + effective inputs; initiator MUST NOT be part of identity. **Enforcement**: Uniqueness MUST be enforced via a partial unique index on `(tenant_id, run_identity_hash)` where status is `queued` or `running`.
|
||||||
|
- **FR-010 Phase 1 identity rules**: Identity rules MUST be defined at least as follows:
|
||||||
|
- `inventory.sync`: tenant + selection scope
|
||||||
|
- `policy.sync`: tenant + effective policy scope
|
||||||
|
- `directory_groups.sync`: tenant + selection (Phase 1 default: “all groups”)
|
||||||
|
- `backup_set.add_policies`: tenant + backup set + selected policies + option flags (if exposed)
|
||||||
|
- `backup_schedule.run_now`: tenant + backup schedule id
|
||||||
|
- `backup_schedule.retry`: tenant + backup schedule id
|
||||||
|
- `drift.generate`: tenant + scope key + baseline/current comparison inputs
|
||||||
|
- **FR-011 Run state presentation**: Monitoring MUST present a consistent run state using a single display bucket derived from lifecycle status and terminal outcome:
|
||||||
|
- If status is `queued` or `running`, display that status.
|
||||||
|
- If status is `completed`, display the terminal outcome derived from the stored token (`succeeded`, `partially_succeeded`, or `failed`) using the UI label mapping.
|
||||||
|
- **FR-012 Partial vs failed (terminal outcomes)**: “Partially succeeded” (`partially_succeeded`) means at least one success and at least one failure; “Failed” (`failed`) means zero successes or cannot proceed.
|
||||||
|
- **FR-013 Failure details are safe + useful**: Failures MUST be persisted and displayed as stable reason codes and short sanitized messages; failures MUST NOT include secrets/tokens/credentials/PII or full external payload dumps.
|
||||||
|
- **Reason codes** MUST be stable, machine-readable identifiers (lowercase, dot-separated), e.g. `graph.throttled`, `auth.forbidden`, `validation.invalid_input`, `unexpected.exception`.
|
||||||
|
- **Messages** MUST be short (≤ 200 characters), sanitized, and written for operators (no secrets/tokens/credentials/PII; no raw external payloads). If needed, messages MAY include a non-sensitive correlation identifier.
|
||||||
|
- **FR-014 Related links**: Run detail MUST include contextual links where applicable (e.g., drift findings, backup set, inventory results, directory groups, restore detail for `restore.execute`).
|
||||||
|
- **FR-015 Notifications**: System MUST emit in-app notifications for “queued” (after start) and terminal outcomes for Phase 1 runs; notifications MUST include a short summary and a “View run” link; recipients are the initiating user only.
|
||||||
|
- If a start request reuses an existing active run (dedupe), the run initiator (as stored on the `OperationRun`) remains the sole notification recipient; the second starter receives no additional notifications.
|
||||||
|
- **FR-016 Tenant isolation**: All run list/detail access MUST be tenant-scoped; cross-tenant access MUST be denied without disclosing run details.
|
||||||
|
- **FR-017 No render-time remote calls**: Monitoring pages MUST be render-safe and MUST NOT depend on external service calls during render.
|
||||||
|
- **FR-018 Roles & permissions**: Roles `Owner`, `Manager`, `Operator`, and `Readonly` MUST be able to view runs; only `Owner`, `Manager`, `Operator` may start operations; `Readonly` is strictly view-only.
|
||||||
|
- **FR-019 Audit-only actions (no OperationRun)**: Actions that are DB-only and complete within ≤2 seconds under normal
|
||||||
|
conditions MAY be executed without an OperationRun, as long as they do not start long-running background execution and
|
||||||
|
do not require any remote/external calls.
|
||||||
|
- **054 scope note**: 054 does not implement or modify audit-only actions. If any audit-only action is touched as part
|
||||||
|
of implementing 054 in the future, it MUST comply with this requirement and MUST be covered by tests.
|
||||||
|
If such an action is security-relevant or changes operational behavior (e.g., “Ignore policy”, “Deactivate tenant”,
|
||||||
|
“Admin consent”, “Prune versions”, “Force delete”), it MUST write exactly one tenant-scoped AuditLog entry with, at minimum:
|
||||||
|
- `tenant_id`
|
||||||
|
- `actor_user_id`
|
||||||
|
- `action` (stable action identifier, e.g., `policy.ignore`)
|
||||||
|
- `target_type`, `target_id`
|
||||||
|
- `before` / `after` (sanitized JSON) **or** `diff` (sanitized JSON)
|
||||||
|
- `created_at`
|
||||||
|
**Trigger guidance (to make classification reviewable)**:
|
||||||
|
- “Security-relevant” includes actions that grant/revoke access, change authorization posture, change admin consent, or otherwise modify who/what can read/write tenant data.
|
||||||
|
- “Operational behavior change” includes actions that change what the system will do in future runs (e.g., ignore/exclude resources, enable/disable schedules, retention/prune/archive actions, force deletes).
|
||||||
|
- If unclear whether an Audit-only action is security/ops-relevant, the default is to treat it as such and write an AuditLog entry.
|
||||||
|
**Sanitization (AuditLog before/after/diff)**:
|
||||||
|
- AuditLog payloads MUST include only the minimum fields needed to understand the change.
|
||||||
|
- AuditLog payloads MUST NOT include secrets/tokens/credentials, personal data, or full external payload dumps.
|
||||||
|
- If a field is sensitive, it MUST be omitted or replaced with a non-sensitive placeholder (e.g., `"[REDACTED]"`).
|
||||||
|
Monitoring/Operations remains reserved for OperationRun-tracked long-running/queued operations.
|
||||||
|
**Acceptance checks (testable)**:
|
||||||
|
- Audit-only action creates no OperationRun.
|
||||||
|
- Audit-only action creates exactly one AuditLog event containing the required fields.
|
||||||
|
- Audit-only action is tenant-scoped; cross-tenant access is forbidden and MUST NOT create AuditLog entries.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Canonical Operation Run**: A tenant-scoped record representing the lifecycle of a long-running operation, including run type, initiator (nullable `user_id` FK + `initiator_name` string), lifecycle state/timestamps, terminal outcome, summary counts, safe failure summaries, idempotency identity (uniqueness enforced by DB index on active runs), and safe context references.
|
||||||
|
- **Restore domain record (exception)**: Restore remains a domain workflow record with richer semantics and history. Monitoring shows restore activity through a physical `operation_runs` row (adapter) that links back to the restore record, without replacing it.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Operators can answer “what ran, when, and did it succeed?” for any Phase 1 run in under 1 minute using Monitoring → Operations.
|
||||||
|
- **SC-002**: Starting a Phase 1 operation returns confirmation + “View run” link within 2 seconds under normal conditions.
|
||||||
|
- **SC-003**: Duplicate starts reuse the same active run in at least 99% of attempts under normal conditions.
|
||||||
|
- **SC-003 Measurement scope (definition)**: An “attempt” counts when a start request is made for an operation with identical effective inputs while an identical run is already `queued` or `running`. The success condition is that the system reuses the existing active run reference rather than creating a second active run. “Normal conditions” exclude infrastructure outages (e.g., database unavailable) that prevent either run creation or dedupe evaluation.
|
||||||
|
- **SC-004**: No secrets/tokens/credentials/PII appear in persisted failures or notifications (verified by tests).
|
||||||
209
specs/054-unify-runs-suitewide/tasks.md
Normal file
209
specs/054-unify-runs-suitewide/tasks.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
description: "Task list for feature implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Unified Operations Runs Suitewide (054)
|
||||||
|
|
||||||
|
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/054-unify-runs-suitewide/`
|
||||||
|
**Prerequisites**: `specs/054-unify-runs-suitewide/plan.md`, `specs/054-unify-runs-suitewide/spec.md`, `specs/054-unify-runs-suitewide/data-model.md`, `specs/054-unify-runs-suitewide/research.md`, `specs/054-unify-runs-suitewide/quickstart.md`, `specs/054-unify-runs-suitewide/contracts/`
|
||||||
|
|
||||||
|
**Tests**: Required (Pest) for runtime behavior changes.
|
||||||
|
**Operations**: Long-running/queued/remote/scheduled actions MUST create/reuse a canonical `OperationRun` and link to Monitoring → Operations. Monitoring pages MUST be DB-only at render time (no remote calls).
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story?] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no blocking dependencies)
|
||||||
|
- **[Story]**: User story label (e.g., `[US1]`, `[US2]`, `[US3]`) — REQUIRED for story phases only
|
||||||
|
- Each task includes at least one concrete file path
|
||||||
|
|
||||||
|
## Path Conventions (Laravel Monolith)
|
||||||
|
|
||||||
|
- Source: `app/`, `routes/`, `resources/`, `config/`, `database/`
|
||||||
|
- Tests: `tests/Feature/`, `tests/Unit/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Spec + Contract Alignment)
|
||||||
|
|
||||||
|
**Purpose**: Resolve ambiguity before implementation starts
|
||||||
|
|
||||||
|
- [x] T001 Validate Phase 1 run type set is consistent (adoption set + FR-003 + identity rules) in `specs/054-unify-runs-suitewide/spec.md`
|
||||||
|
- [x] T002 Validate Monitoring Operations routes match Filament surface + OpenAPI contract in `specs/054-unify-runs-suitewide/contracts/routes.md` and `specs/054-unify-runs-suitewide/contracts/admin-pages.openapi.yaml`
|
||||||
|
- [x] T003 Validate `OperationRunService` contract + quickstart usage examples align with intended API in `specs/054-unify-runs-suitewide/contracts/service_interface.md` and `specs/054-unify-runs-suitewide/quickstart.md`
|
||||||
|
- [x] T004 Confirm FR-019 (Audit-only) is out-of-scope for 054 unless an audit-only action is touched; if touched, add AuditLog implementation + tests per `specs/054-unify-runs-suitewide/spec.md` in `specs/054-unify-runs-suitewide/tasks.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Canonical Run Primitive)
|
||||||
|
|
||||||
|
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||||
|
|
||||||
|
- [x] T005 Create `operation_runs` migration (columns + indexes + partial unique index) in `database/migrations/*_create_operation_runs_table.php`
|
||||||
|
- [x] T006 Create `OperationRun` Eloquent model (casts + relationships + helpers) in `app/Models/OperationRun.php`
|
||||||
|
- [x] T007 [P] Add `OperationRunStatus` enum in `app/Support/OperationRunStatus.php`
|
||||||
|
- [x] T008 [P] Add `OperationRunOutcome` enum (stored tokens vs UI labels; `cancelled` reserved) in `app/Support/OperationRunOutcome.php`
|
||||||
|
- [x] T009 [P] Add `OperationRunType` enum (Phase 1 run types) in `app/Support/OperationRunType.php`
|
||||||
|
- [x] T010 [P] Add `OperationRunFactory` for tests in `database/factories/OperationRunFactory.php`
|
||||||
|
- [x] T011 Implement idempotent create/reuse + lifecycle updates + failure sanitization in `app/Services/OperationRunService.php` (depends on T005–T010)
|
||||||
|
- [x] T012 Centralize “View run” + deep links in `app/Support/OperationRunLinks.php` (depends on T011)
|
||||||
|
- [x] T013 [P] Implement tenant-scoped authorization policy in `app/Policies/OperationRunPolicy.php`
|
||||||
|
- [x] T014 Register `OperationRun` policy with Gate in `app/Providers/AppServiceProvider.php`
|
||||||
|
- [x] T015 Implement job middleware lifecycle tracking in `app/Jobs/Middleware/TrackOperationRun.php` (depends on T011)
|
||||||
|
- [x] T016 Implement 90-day retention pruning job in `app/Jobs/PruneOldOperationRunsJob.php`
|
||||||
|
- [x] T017 Schedule pruning job daily with non-overlapping lock (`withoutOverlapping` or equivalent cache lock) in `routes/console.php`
|
||||||
|
- [x] T018 Add scheduled pruning non-overlap regression test (if feasible) in `tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php`
|
||||||
|
- [x] T019 Add `OperationRunService` tests (hash stability, idempotency, unique-index race, sanitization) in `tests/Feature/OperationRunServiceTest.php`
|
||||||
|
- [x] T020 Add `TrackOperationRun` middleware lifecycle tests in `tests/Feature/TrackOperationRunMiddlewareTest.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See Every Supported Operation in Monitoring (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Monitoring → Operations shows all canonical runs (tenant-scoped) with list + detail, filters, and safe failures.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger at least one run of each Phase 1 run producer, then verify list/detail in Monitoring render DB-only and are tenant-scoped per `specs/054-unify-runs-suitewide/spec.md`.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T021 [US1] Add Monitoring list/detail authorization + tenant isolation tests in `tests/Feature/MonitoringOperationsTest.php`
|
||||||
|
- [x] T022 [P] [US1] Add Monitoring DB-only render test (mock Graph client; assert never called) in `tests/Feature/MonitoringOperationsTest.php`
|
||||||
|
- [x] T023 [P] [US1] Add restore adapter visibility tests (created/visible from `previewed` onward; `previewed` maps to `queued/pending`) in `tests/Feature/RestoreAdapterTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T024 [US1] Create Filament Monitoring resource (list + view-only) in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [x] T025 [US1] Implement list columns + default sort + default last-30-days window in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [x] T026 [US1] Implement list filters (type, state bucket, time range, initiator) in `app/Filament/Resources/OperationRunResource.php`
|
||||||
|
- [x] T027 [US1] Implement run detail view (meta, summary_counts, failures, context, links) in `app/Filament/Resources/OperationRunResource/Pages/ViewOperationRun.php`
|
||||||
|
- [x] T028 [US1] Implement related links for each Phase 1 run type in `app/Support/OperationRunLinks.php`
|
||||||
|
- [x] T029 [US1] Implement RestoreRun → OperationRun adapter create/update (from `previewed` onward; `previewed` maps to `status=queued`, `outcome=pending`) in `app/Listeners/SyncRestoreRunToOperationRun.php`
|
||||||
|
- [x] T030 [US1] Wire RestoreRun lifecycle events to adapter in `app/Observers/RestoreRunObserver.php`
|
||||||
|
- [x] T031 [US1] Register RestoreRun observer in `app/Providers/AppServiceProvider.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Start Operations Without Blocking (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Start surfaces are enqueue-only, return immediate confirmation + “View run”, and jobs update canonical run lifecycle.
|
||||||
|
|
||||||
|
**Independent Test**: Start each Phase 1 operation from its owning UI and confirm the request returns quickly, includes “View run”, and the run progresses queued → running → terminal outcome.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T032 [P] [US2] Add Inventory “Sync now” start-surface tests in `tests/Feature/Inventory/InventorySyncStartSurfaceTest.php`
|
||||||
|
- [x] T033 [P] [US2] Add Policies “Sync now” start-surface tests in `tests/Feature/PolicySyncStartSurfaceTest.php`
|
||||||
|
- [x] T034 [P] [US2] Add Directory Groups “Sync groups” start-surface tests in `tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php`
|
||||||
|
- [x] T035 [P] [US2] Add Drift “Generate drift” start-surface tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
||||||
|
- [x] T036 [P] [US2] Add Backup Set “Add policies” start-surface tests in `tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php`
|
||||||
|
- [x] T037 [P] [US2] Add Backup Schedule “Run now/Retry” start-surface tests in `tests/Feature/BackupScheduling/RunNowRetryActionsTest.php`
|
||||||
|
- [x] T067 [P] [US2] Add Backup Set “Remove policies” (row + bulk) start-surface tests in `tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php` and `tests/Feature/Filament/BackupItemsBulkRemoveTest.php`
|
||||||
|
- [x] T038 [P] [US2] Add “queued + terminal” notification tests (initiator only; safe content) in `tests/Feature/Notifications/OperationRunNotificationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T039 [P] [US2] Refactor Inventory “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Pages/InventoryLanding.php`
|
||||||
|
- [x] T040 [P] [US2] Refactor Policies “Sync now” to ensure run + dispatch + “View run” in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
|
||||||
|
- [x] T041 [P] [US2] Refactor Directory Groups “Sync groups” to ensure run + dispatch + “View run” in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||||
|
- [x] T042 [P] [US2] Refactor Drift “Generate drift” (manual + auto-on-open) to ensure run + dispatch + “View run” in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
- [x] T043 [P] [US2] Refactor Backup Set “Add policies” Livewire action to ensure run + dispatch + “View run” in `app/Livewire/BackupSetPolicyPickerTable.php`
|
||||||
|
- [x] T044 [P] [US2] Refactor Backup Schedule “Run now/Retry” actions to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupScheduleResource.php`
|
||||||
|
- [x] T068 [P] [US2] Refactor Backup Set “Remove policies” actions (row + bulk) to ensure run + dispatch + “View run” in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` and `app/Jobs/RemovePoliciesFromBackupSetJob.php`
|
||||||
|
- [x] T045 [P] [US2] Instrument inventory sync job run lifecycle + summary/failures in `app/Jobs/RunInventorySyncJob.php`
|
||||||
|
- [x] T046 [P] [US2] Instrument policy sync job run lifecycle + summary/failures in `app/Jobs/SyncPoliciesJob.php`
|
||||||
|
- [x] T047 [P] [US2] Instrument groups sync job run lifecycle + summary/failures in `app/Jobs/EntraGroupSyncJob.php`
|
||||||
|
- [x] T048 [P] [US2] Instrument drift generation job run lifecycle + summary/failures in `app/Jobs/GenerateDriftFindingsJob.php`
|
||||||
|
- [x] T049 [P] [US2] Instrument backup set “add policies” job run lifecycle + summary/failures in `app/Jobs/AddPoliciesToBackupSetJob.php`
|
||||||
|
- [x] T050 [P] [US2] Instrument backup schedule job run lifecycle + summary/failures in `app/Jobs/RunBackupScheduleJob.php`
|
||||||
|
- [x] T051 [US2] Implement queued notification (after successful dispatch) in `app/Notifications/OperationRunQueued.php`
|
||||||
|
- [x] T052 [US2] Implement terminal outcome notification (succeeded/partial/failed) in `app/Notifications/OperationRunCompleted.php`
|
||||||
|
- [x] T053 [US2] Emit notifications from canonical lifecycle updates (initiator only) in `app/Services/OperationRunService.php`
|
||||||
|
- [x] T054 [US2] Handle queue dispatch failures (fail fast; no misleading queued runs) in `app/Services/OperationRunService.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Duplicate Starts Reuse the Same Active Run (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Duplicate starts reuse the same active run (dedupe), enforced at DB level and validated by tests.
|
||||||
|
|
||||||
|
**Independent Test**: Start the same operation twice with identical effective inputs while the first is queued/running and verify the system reuses the active run.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T055 [US3] Add service-level dedupe + race-collision tests in `tests/Feature/OperationRunServiceTest.php`
|
||||||
|
- [x] T056 [US3] Add end-to-end “reuse active run” test for at least one producer in `tests/Feature/PolicySyncStartSurfaceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T057 [US3] Normalize identity inputs before hashing (stable JSON; ignore initiator) in `app/Services/OperationRunService.php`
|
||||||
|
- [x] T058 [P] [US3] Ensure `inventory.sync` identity inputs follow FR-010 in `app/Filament/Pages/InventoryLanding.php`
|
||||||
|
- [x] T059 [P] [US3] Ensure `policy.sync` identity inputs follow FR-010 in `app/Filament/Resources/PolicyResource/Pages/ListPolicies.php`
|
||||||
|
- [x] T060 [P] [US3] Ensure `directory_groups.sync` identity inputs follow FR-010 in `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||||
|
- [x] T061 [P] [US3] Ensure `drift.generate` identity inputs follow FR-010 in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
- [x] T062 [P] [US3] Ensure `backup_set.add_policies` identity inputs follow FR-010 in `app/Livewire/BackupSetPolicyPickerTable.php`
|
||||||
|
- [x] T063 [P] [US3] Ensure `backup_schedule.run_now`/`backup_schedule.retry` identity inputs follow FR-010 in `app/Filament/Resources/BackupScheduleResource.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T064 [P] Run formatter on changed files via `./vendor/bin/pint --dirty`
|
||||||
|
- [x] T065 Run targeted tests for this feature via `./vendor/bin/sail artisan test tests/Feature/MonitoringOperationsTest.php`
|
||||||
|
- [x] T066 Run quickstart scenarios and update docs if needed in `specs/054-unify-runs-suitewide/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- Setup (Phase 1) → blocks Foundational
|
||||||
|
- Foundational (Phase 2) → blocks US1/US2/US3
|
||||||
|
- US1/US2/US3 can proceed after Phase 2 (parallel if staffed, or sequential P1 → P2 → P3)
|
||||||
|
- Polish (Phase 6) depends on the desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies (Graph)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Foundational
|
||||||
|
├─ US1 (Monitoring UI)
|
||||||
|
├─ US2 (Start surfaces + lifecycle + notifications)
|
||||||
|
└─ US3 (Dedupe + identity + race handling)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
After Phase 2, these can run in parallel (different files/modules):
|
||||||
|
|
||||||
|
### US1 (Monitoring UI)
|
||||||
|
|
||||||
|
- `T023` (restore adapter tests) + `T024` (resource scaffold) + `T029` (restore adapter listener)
|
||||||
|
|
||||||
|
### US2 (Start surfaces + lifecycle + notifications)
|
||||||
|
|
||||||
|
- Tests: `T032`–`T038`
|
||||||
|
- Producers/start surfaces: `T039`–`T044`
|
||||||
|
- Workers/job instrumentation: `T045`–`T050`
|
||||||
|
- Notification classes: `T051` + `T052`
|
||||||
|
|
||||||
|
### US3 (Dedupe + identity + race handling)
|
||||||
|
|
||||||
|
- Identity review per producer: `T058`–`T063`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (US1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1–2 (canonical run primitive)
|
||||||
|
2. Complete US1 (Monitoring list/detail + tenant isolation)
|
||||||
|
3. Validate Monitoring renders DB-only and is tenant-scoped
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Add US2 producers/workers + notifications
|
||||||
|
2. Add US3 dedupe + race validation
|
||||||
|
3. Polish (formatting, targeted tests, quickstart validation)
|
||||||
|
|
||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
@ -37,6 +38,170 @@
|
|||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.run_now',
|
||||||
|
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
|
||||||
|
{
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array
|
||||||
|
{
|
||||||
|
return ['synced' => [], 'failures' => []];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BackupSet $backupSet) {}
|
||||||
|
|
||||||
|
public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet
|
||||||
|
{
|
||||||
|
return $this->backupSet;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
||||||
|
app(PolicySyncService::class),
|
||||||
|
app(BackupService::class),
|
||||||
|
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||||
|
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||||
|
app(\App\Services\Intune\AuditLogger::class),
|
||||||
|
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||||
|
app(BulkOperationService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
|
||||||
|
expect($run->backup_set_id)->toBe($backupSet->id);
|
||||||
|
|
||||||
|
$operationRun->refresh();
|
||||||
|
expect($operationRun->status)->toBe('completed');
|
||||||
|
expect($operationRun->outcome)->toBe('succeeded');
|
||||||
|
expect($operationRun->summary_counts)->toMatchArray([
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
|
'backup_set_id' => (int) $backupSet->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips runs when all policy types are unknown', function () {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$schedule = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Daily 10:00',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '10:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['definitelyNotARealPolicyType'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
'next_run_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = BackupScheduleRun::query()->create([
|
||||||
|
'backup_schedule_id' => $schedule->id,
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
||||||
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.run_now',
|
||||||
|
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
||||||
|
app(PolicySyncService::class),
|
||||||
|
app(BackupService::class),
|
||||||
|
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||||
|
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||||
|
app(\App\Services\Intune\AuditLogger::class),
|
||||||
|
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||||
|
app(BulkOperationService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
|
||||||
|
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
|
||||||
|
expect($run->backup_set_id)->toBeNull();
|
||||||
|
|
||||||
|
$operationRun->refresh();
|
||||||
|
expect($operationRun->status)->toBe('completed');
|
||||||
|
expect($operationRun->outcome)->toBe('failed');
|
||||||
|
expect($operationRun->failure_summary)->toMatchArray([
|
||||||
|
['code' => 'unknown_policy_type', 'message' => $run->error_message],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the operation run based on the backup schedule run id when not passed into the job', function () {
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$schedule = BackupSchedule::query()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Daily 10:00',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '10:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
'next_run_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = BackupScheduleRun::query()->create([
|
||||||
|
'backup_schedule_id' => $schedule->id,
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
||||||
|
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'backup_schedule.run_now',
|
||||||
|
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$operationRun->update([
|
||||||
|
'context' => array_merge($operationRun->context ?? [], [
|
||||||
|
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
|
app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService
|
||||||
{
|
{
|
||||||
public function __construct() {}
|
public function __construct() {}
|
||||||
@ -75,52 +240,12 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
app(BulkOperationService::class),
|
app(BulkOperationService::class),
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$operationRun->refresh();
|
||||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
|
expect($operationRun->status)->toBe('completed');
|
||||||
expect($run->backup_set_id)->toBe($backupSet->id);
|
expect($operationRun->outcome)->toBe('succeeded');
|
||||||
});
|
expect($operationRun->summary_counts)->toMatchArray([
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
it('skips runs when all policy types are unknown', function () {
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
'backup_set_id' => (int) $backupSet->id,
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$schedule = BackupSchedule::query()->create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'name' => 'Daily 10:00',
|
|
||||||
'is_enabled' => true,
|
|
||||||
'timezone' => 'UTC',
|
|
||||||
'frequency' => 'daily',
|
|
||||||
'time_of_day' => '10:00:00',
|
|
||||||
'days_of_week' => null,
|
|
||||||
'policy_types' => ['definitelyNotARealPolicyType'],
|
|
||||||
'include_foundations' => true,
|
|
||||||
'retention_keep_last' => 30,
|
|
||||||
'next_run_at' => null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$run = BackupScheduleRun::query()->create([
|
|
||||||
'backup_schedule_id' => $schedule->id,
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
|
||||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Cache::flush();
|
|
||||||
|
|
||||||
(new RunBackupScheduleJob($run->id))->handle(
|
|
||||||
app(PolicySyncService::class),
|
|
||||||
app(BackupService::class),
|
|
||||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
|
||||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
|
||||||
app(\App\Services\Intune\AuditLogger::class),
|
|
||||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
|
||||||
app(BulkOperationService::class),
|
|
||||||
);
|
|
||||||
|
|
||||||
$run->refresh();
|
|
||||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
|
|
||||||
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
|
|
||||||
expect($run->backup_set_id)->toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,14 +5,29 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Notifications\OperationRunQueued;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('operator can run now and it persists a database notification', function () {
|
test('operator can run now and it persists a database notification', function () {
|
||||||
Queue::fake();
|
Queue::fake([RunBackupScheduleJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
@ -42,6 +57,17 @@
|
|||||||
expect($run)->not->toBeNull();
|
expect($run)->not->toBeNull();
|
||||||
expect($run->user_id)->toBe($user->id);
|
expect($run->user_id)->toBe($user->id);
|
||||||
|
|
||||||
|
$operationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('type', 'backup_schedule.run_now')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($operationRun)->not->toBeNull();
|
||||||
|
expect($operationRun->context)->toMatchArray([
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
|
]);
|
||||||
|
|
||||||
expect(BulkOperationRun::query()
|
expect(BulkOperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
@ -50,19 +76,29 @@
|
|||||||
->count())
|
->count())
|
||||||
->toBe(1);
|
->toBe(1);
|
||||||
|
|
||||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||||
|
return $job->backupScheduleRunId === (int) $run->id
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& $job->operationRun->is($operationRun);
|
||||||
|
});
|
||||||
|
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$this->assertDatabaseCount('notifications', 1);
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->id,
|
'notifiable_id' => $user->id,
|
||||||
'notifiable_type' => User::class,
|
'notifiable_type' => User::class,
|
||||||
|
'type' => OperationRunQueued::class,
|
||||||
'data->format' => 'filament',
|
'data->format' => 'filament',
|
||||||
'data->title' => 'Run dispatched',
|
'data->title' => 'Operation queued',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator can retry and it persists a database notification', function () {
|
test('operator can retry and it persists a database notification', function () {
|
||||||
Queue::fake();
|
Queue::fake([RunBackupScheduleJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
@ -92,6 +128,17 @@
|
|||||||
expect($run)->not->toBeNull();
|
expect($run)->not->toBeNull();
|
||||||
expect($run->user_id)->toBe($user->id);
|
expect($run->user_id)->toBe($user->id);
|
||||||
|
|
||||||
|
$operationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('type', 'backup_schedule.retry')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($operationRun)->not->toBeNull();
|
||||||
|
expect($operationRun->context)->toMatchArray([
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
|
]);
|
||||||
|
|
||||||
expect(BulkOperationRun::query()
|
expect(BulkOperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
@ -100,13 +147,24 @@
|
|||||||
->count())
|
->count())
|
||||||
->toBe(1);
|
->toBe(1);
|
||||||
|
|
||||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||||
|
return $job->backupScheduleRunId === (int) $run->id
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& $job->operationRun->is($operationRun);
|
||||||
|
});
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$this->assertDatabaseCount('notifications', 1);
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->id,
|
'notifiable_id' => $user->id,
|
||||||
|
'notifiable_type' => User::class,
|
||||||
|
'type' => OperationRunQueued::class,
|
||||||
'data->format' => 'filament',
|
'data->format' => 'filament',
|
||||||
'data->title' => 'Retry dispatched',
|
'data->title' => 'Operation queued',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($operationRun, $tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readonly cannot dispatch run now or retry', function () {
|
test('readonly cannot dispatch run now or retry', function () {
|
||||||
@ -144,10 +202,16 @@
|
|||||||
|
|
||||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||||
->toBe(0);
|
->toBe(0);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||||
|
->count())
|
||||||
|
->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator can bulk run now and it persists a database notification', function () {
|
test('operator can bulk run now and it persists a database notification', function () {
|
||||||
Queue::fake();
|
Queue::fake([RunBackupScheduleJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
@ -189,6 +253,12 @@
|
|||||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
||||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('type', 'backup_schedule.run_now')
|
||||||
|
->count())
|
||||||
|
->toBe(2);
|
||||||
|
|
||||||
expect(BulkOperationRun::query()
|
expect(BulkOperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
@ -204,10 +274,15 @@
|
|||||||
'data->format' => 'filament',
|
'data->format' => 'filament',
|
||||||
'data->title' => 'Runs dispatched',
|
'data->title' => 'Runs dispatched',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::index($tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator can bulk retry and it persists a database notification', function () {
|
test('operator can bulk retry and it persists a database notification', function () {
|
||||||
Queue::fake();
|
Queue::fake([RunBackupScheduleJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
@ -249,6 +324,12 @@
|
|||||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id);
|
||||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->id)
|
||||||
|
->where('type', 'backup_schedule.retry')
|
||||||
|
->count())
|
||||||
|
->toBe(2);
|
||||||
|
|
||||||
expect(BulkOperationRun::query()
|
expect(BulkOperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
@ -264,10 +345,15 @@
|
|||||||
'data->format' => 'filament',
|
'data->format' => 'filament',
|
||||||
'data->title' => 'Retries dispatched',
|
'data->title' => 'Retries dispatched',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::index($tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator can bulk retry even if a run already exists for this minute', function () {
|
test('operator can bulk retry even if a run already exists for this minute', function () {
|
||||||
Queue::fake();
|
Queue::fake([RunBackupScheduleJob::class]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||||
|
|
||||||
|
|||||||
70
tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php
Normal file
70
tests/Feature/BackupSets/AddPoliciesStartSurfaceTest.php
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||||
|
use App\Livewire\BackupSetPolicyPickerTable;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('enqueues backup set add-policies via canonical operation run (no Graph calls in request)', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Test backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policies = Policy::factory()->count(2)->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'ignored_at' => null,
|
||||||
|
'last_synced_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(BackupSetPolicyPickerTable::class, [
|
||||||
|
'backupSetId' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->callTableBulkAction('add_selected_to_backup_set', $policies)
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'backup_set.add_policies')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
expect($opRun?->status)->toBe('queued');
|
||||||
|
expect($opRun?->outcome)->toBe('pending');
|
||||||
|
expect($opRun?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
|
||||||
|
|
||||||
|
$notifications = session('filament.notifications', []);
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
|
||||||
|
Queue::assertPushed(AddPoliciesToBackupSetJob::class, function (AddPoliciesToBackupSetJob $job) use ($opRun): bool {
|
||||||
|
return $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\DatabaseNotification;
|
||||||
|
|
||||||
|
it('remove policies job sends completion notification with view link', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'name' => 'Test backup',
|
||||||
|
'item_count' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$item = BackupItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'backup_set_id' => $backupSet->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet->update(['item_count' => $backupSet->items()->count()]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'backup_set.remove_policies',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => 'remove-hash-1',
|
||||||
|
'context' => [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'backup_item_ids' => [(int) $item->getKey()],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(AuditLogger::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('log')->zeroOrMoreTimes();
|
||||||
|
});
|
||||||
|
|
||||||
|
$job = new RemovePoliciesFromBackupSetJob(
|
||||||
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
|
backupItemIds: [(int) $item->getKey()],
|
||||||
|
initiatorUserId: (int) $user->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job->handle(app(AuditLogger::class), app(BulkOperationService::class));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
'notifiable_id' => $user->getKey(),
|
||||||
|
'notifiable_type' => $user->getMorphClass(),
|
||||||
|
'type' => DatabaseNotification::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
});
|
||||||
60
tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php
Normal file
60
tests/Feature/BackupSets/RemovePoliciesStartSurfaceTest.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
|
||||||
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('queues a canonical operation run for row remove and does not call Graph during the request', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Test backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::factory()->for($backupSet)->for($tenant)->create();
|
||||||
|
|
||||||
|
Livewire::test(BackupItemsRelationManager::class, [
|
||||||
|
'ownerRecord' => $backupSet,
|
||||||
|
'pageClass' => EditBackupSet::class,
|
||||||
|
])
|
||||||
|
->callTableAction('remove', $backupItem);
|
||||||
|
|
||||||
|
Queue::assertPushed(RemovePoliciesFromBackupSetJob::class, function (RemovePoliciesFromBackupSetJob $job) use ($backupSet, $backupItem, $user) {
|
||||||
|
return $job->backupSetId === (int) $backupSet->getKey()
|
||||||
|
&& $job->backupItemIds === [(int) $backupItem->getKey()]
|
||||||
|
&& $job->initiatorUserId === (int) $user->getKey();
|
||||||
|
});
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'backup_set.remove_policies')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->status)->toBe('queued');
|
||||||
|
expect($run->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
|
||||||
|
});
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BackupSchedule;
|
||||||
|
use App\Models\BackupScheduleRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('reconciles completed backup schedule runs into operation runs', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$schedule = BackupSchedule::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Daily',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'time_of_day' => '10:00:00',
|
||||||
|
'days_of_week' => null,
|
||||||
|
'policy_types' => ['deviceConfiguration'],
|
||||||
|
'include_foundations' => true,
|
||||||
|
'retention_keep_last' => 30,
|
||||||
|
'last_run_at' => null,
|
||||||
|
'last_run_status' => null,
|
||||||
|
'next_run_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
|
||||||
|
$finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC');
|
||||||
|
|
||||||
|
$scheduleRun = BackupScheduleRun::create([
|
||||||
|
'backup_schedule_id' => $schedule->id,
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'),
|
||||||
|
'started_at' => $startedAt,
|
||||||
|
'finished_at' => $finishedAt,
|
||||||
|
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||||
|
'summary' => [
|
||||||
|
'policies_total' => 5,
|
||||||
|
'policies_backed_up' => 18,
|
||||||
|
'sync_failures' => [],
|
||||||
|
],
|
||||||
|
'error_code' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
'backup_set_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$operationRun = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'user_id' => null,
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'type' => 'backup_schedule.run_now',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id),
|
||||||
|
'summary_counts' => [],
|
||||||
|
'failure_summary' => [],
|
||||||
|
'context' => [
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
|
'backup_schedule_run_id' => (int) $scheduleRun->id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [
|
||||||
|
'--tenant' => [$tenant->id],
|
||||||
|
'--older-than' => 0,
|
||||||
|
])->assertSuccessful();
|
||||||
|
|
||||||
|
$operationRun->refresh();
|
||||||
|
|
||||||
|
expect($operationRun->status)->toBe('completed');
|
||||||
|
expect($operationRun->outcome)->toBe('succeeded');
|
||||||
|
expect($operationRun->failure_summary)->toBe([]);
|
||||||
|
|
||||||
|
expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s'));
|
||||||
|
expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s'));
|
||||||
|
|
||||||
|
expect($operationRun->summary_counts)->toMatchArray([
|
||||||
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
|
'backup_schedule_run_id' => (int) $scheduleRun->id,
|
||||||
|
'policies_total' => 5,
|
||||||
|
'policies_backed_up' => 18,
|
||||||
|
'sync_failures' => 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
|
||||||
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('enqueues group sync and creates a canonical operation run', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListEntraGroups::class)
|
||||||
|
->callAction('sync_groups');
|
||||||
|
|
||||||
|
$run = EntraGroupSyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
|
||||||
|
expect($run?->selection_key)->toBe('groups-v1:all');
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'directory_groups.sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
expect($opRun?->status)->toBe('queued');
|
||||||
|
|
||||||
|
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $run, $opRun): bool {
|
||||||
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->runId === (int) $run?->getKey()
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides group sync start action for readonly users', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListEntraGroups::class)
|
||||||
|
->assertActionHidden('sync_groups');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
@ -1,16 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\DriftLanding;
|
use App\Filament\Pages\DriftLanding;
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\RunIdempotency;
|
use App\Support\RunIdempotency;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('opening Drift dispatches generation when findings are missing', function () {
|
test('opening Drift dispatches generation when findings are missing', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
@ -61,24 +73,30 @@
|
|||||||
'current_run_id' => (int) $current->getKey(),
|
'current_run_id' => (int) $current->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notifications', [
|
$opRun = OperationRun::query()
|
||||||
'notifiable_id' => $user->getKey(),
|
->where('tenant_id', $tenant->getKey())
|
||||||
'notifiable_type' => $user->getMorphClass(),
|
->where('type', 'drift.generate')
|
||||||
'type' => RunStatusChangedNotification::class,
|
->latest('id')
|
||||||
]);
|
->first();
|
||||||
|
|
||||||
$notification = $user->notifications()->latest('id')->first();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($notification)->not->toBeNull();
|
expect($opRun?->status)->toBe('queued');
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
|
||||||
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $bulkRun->getKey()], tenant: $tenant));
|
|
||||||
|
|
||||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool {
|
$notifications = session('filament.notifications', []);
|
||||||
|
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
|
||||||
|
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool {
|
||||||
return $job->tenantId === (int) $tenant->getKey()
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
&& $job->userId === (int) $user->getKey()
|
&& $job->userId === (int) $user->getKey()
|
||||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||||
&& $job->currentRunId === (int) $current->getKey()
|
&& $job->currentRunId === (int) $current->getKey()
|
||||||
&& $job->scopeKey === $scopeKey
|
&& $job->scopeKey === $scopeKey
|
||||||
&& $job->bulkOperationRunId === (int) $bulkRun->getKey();
|
&& $job->bulkOperationRunId === (int) $bulkRun->getKey()
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,6 +141,11 @@
|
|||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('idempotency_key', $idempotencyKey)
|
->where('idempotency_key', $idempotencyKey)
|
||||||
->count())->toBe(1);
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'drift.generate')
|
||||||
|
->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
||||||
@ -144,6 +167,7 @@
|
|||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening Drift does not dispatch generation for readonly users', function () {
|
test('opening Drift does not dispatch generation for readonly users', function () {
|
||||||
@ -171,4 +195,5 @@
|
|||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\BulkOperationRun;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\BulkOperationService;
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\DatabaseNotification;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
test('drift generation job sends completion notification with view link', function () {
|
test('drift generation job sends completion notification with view link', function () {
|
||||||
@ -40,6 +41,21 @@
|
|||||||
'failures' => [],
|
'failures' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'drift.generate',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => 'drift-hash-1',
|
||||||
|
'context' => [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
||||||
$mock->shouldReceive('generate')->once()->andReturn(0);
|
$mock->shouldReceive('generate')->once()->andReturn(0);
|
||||||
});
|
});
|
||||||
@ -51,6 +67,7 @@
|
|||||||
currentRunId: (int) $current->getKey(),
|
currentRunId: (int) $current->getKey(),
|
||||||
scopeKey: $scopeKey,
|
scopeKey: $scopeKey,
|
||||||
bulkOperationRunId: (int) $run->getKey(),
|
bulkOperationRunId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class));
|
$job->handle(app(DriftFindingGenerator::class), app(BulkOperationService::class));
|
||||||
@ -60,13 +77,13 @@
|
|||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->getKey(),
|
'notifiable_id' => $user->getKey(),
|
||||||
'notifiable_type' => $user->getMorphClass(),
|
'notifiable_type' => $user->getMorphClass(),
|
||||||
'type' => RunStatusChangedNotification::class,
|
'type' => DatabaseNotification::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drift generation job sends failure notification with view link', function () {
|
test('drift generation job sends failure notification with view link', function () {
|
||||||
@ -100,6 +117,21 @@
|
|||||||
'failures' => [],
|
'failures' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'drift.generate',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => 'drift-hash-2',
|
||||||
|
'context' => [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
$this->mock(DriftFindingGenerator::class, function (MockInterface $mock) {
|
||||||
$mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom'));
|
$mock->shouldReceive('generate')->once()->andThrow(new RuntimeException('boom'));
|
||||||
});
|
});
|
||||||
@ -111,6 +143,7 @@
|
|||||||
currentRunId: (int) $current->getKey(),
|
currentRunId: (int) $current->getKey(),
|
||||||
scopeKey: $scopeKey,
|
scopeKey: $scopeKey,
|
||||||
bulkOperationRunId: (int) $run->getKey(),
|
bulkOperationRunId: (int) $run->getKey(),
|
||||||
|
operationRun: $opRun,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -133,11 +166,11 @@
|
|||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->getKey(),
|
'notifiable_id' => $user->getKey(),
|
||||||
'notifiable_type' => $user->getMorphClass(),
|
'notifiable_type' => $user->getMorphClass(),
|
||||||
'type' => RunStatusChangedNotification::class,
|
'type' => DatabaseNotification::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(BulkOperationRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||||
|
use App\Jobs\RemovePoliciesFromBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('backup items table can bulk remove selected items', function () {
|
test('backup items table can bulk remove selected items', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
$user = User::factory()->create();
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$backupSet = BackupSet::factory()->create([
|
$backupSet = BackupSet::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@ -64,11 +68,21 @@
|
|||||||
->callTableBulkAction('bulk_remove', collect([$itemA, $itemB]))
|
->callTableBulkAction('bulk_remove', collect([$itemA, $itemB]))
|
||||||
->assertHasNoTableBulkActionErrors();
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
Queue::assertPushed(RemovePoliciesFromBackupSetJob::class);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'backup_set.remove_policies')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->outcome)->toBe('pending');
|
||||||
|
expect($run?->context['backup_set_id'] ?? null)->toBe((int) $backupSet->getKey());
|
||||||
|
|
||||||
|
// Enqueue-only: deletion happens in the job.
|
||||||
$backupSet->refresh();
|
$backupSet->refresh();
|
||||||
|
expect($backupSet->items()->count())->toBe(2);
|
||||||
expect($backupSet->items()->count())->toBe(0);
|
expect($backupSet->item_count)->toBe(2);
|
||||||
expect($backupSet->item_count)->toBe(0);
|
|
||||||
|
|
||||||
$this->assertSoftDeleted('backup_items', ['id' => $itemA->id]);
|
|
||||||
$this->assertSoftDeleted('backup_items', ['id' => $itemB->id]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
58
tests/Feature/Inventory/InventorySyncStartSurfaceTest.php
Normal file
58
tests/Feature/Inventory/InventorySyncStartSurfaceTest.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\InventoryLanding;
|
||||||
|
use App\Jobs\RunInventorySyncJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Inventory\InventorySyncService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('enqueues inventory sync and creates a canonical operation run without calling Graph in request', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$sync = app(InventorySyncService::class);
|
||||||
|
$policyTypes = $sync->defaultSelectionPayload()['policy_types'] ?? [];
|
||||||
|
|
||||||
|
Livewire::test(InventoryLanding::class)
|
||||||
|
->callAction('run_inventory_sync', data: ['policy_types' => $policyTypes]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'inventory.sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
expect($opRun?->status)->toBe('queued');
|
||||||
|
expect($opRun?->outcome)->toBe('pending');
|
||||||
|
|
||||||
|
$notifications = session('filament.notifications', []);
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
|
||||||
|
Queue::assertPushed(RunInventorySyncJob::class, function (RunInventorySyncJob $job) use ($tenant, $user, $opRun): bool {
|
||||||
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->userId === (int) $user->getKey()
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,7 +23,7 @@
|
|||||||
$selectionPayload = $sync->defaultSelectionPayload();
|
$selectionPayload = $sync->defaultSelectionPayload();
|
||||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||||
$policyTypes = $computed['selection']['policy_types'];
|
$policyTypes = $computed['selection']['policy_types'];
|
||||||
$run = $sync->createPendingRunForUser($tenant, $user, $selectionPayload);
|
$run = $sync->createPendingRunForUser($tenant, $user, $computed['selection']);
|
||||||
|
|
||||||
$bulkRun = app(BulkOperationService::class)->createRun(
|
$bulkRun = app(BulkOperationService::class)->createRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -52,8 +52,10 @@
|
|||||||
expect($run->finished_at)->not->toBeNull();
|
expect($run->finished_at)->not->toBeNull();
|
||||||
|
|
||||||
expect($bulkRun->status)->toBe('completed');
|
expect($bulkRun->status)->toBe('completed');
|
||||||
expect($bulkRun->processed_items)->toBe(count($policyTypes));
|
expect($bulkRun->failed)->toBe(0);
|
||||||
expect($bulkRun->succeeded)->toBe(count($policyTypes));
|
expect($bulkRun->skipped)->toBe(0);
|
||||||
|
expect($bulkRun->processed_items)->toBeGreaterThan(0);
|
||||||
|
expect($bulkRun->processed_items)->toBe($bulkRun->succeeded);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
|
it('maps skipped inventory sync runs to bulk progress as skipped with reason', function () {
|
||||||
@ -66,6 +68,8 @@
|
|||||||
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
$computed = $sync->normalizeAndHashSelection($selectionPayload);
|
||||||
$policyTypes = $computed['selection']['policy_types'];
|
$policyTypes = $computed['selection']['policy_types'];
|
||||||
|
|
||||||
|
$run->update(['selection_payload' => $computed['selection']]);
|
||||||
|
|
||||||
$bulkRun = app(BulkOperationService::class)->createRun(
|
$bulkRun = app(BulkOperationService::class)->createRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
user: $user,
|
user: $user,
|
||||||
|
|||||||
140
tests/Feature/MonitoringOperationsTest.php
Normal file
140
tests/Feature/MonitoringOperationsTest.php
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
|
||||||
|
it('allows access to monitoring page for tenant members', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant->users()->attach($user);
|
||||||
|
|
||||||
|
$run = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'type' => 'test.run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'run_identity_hash' => 'hash123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('test.run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders monitoring pages DB-only (never calls Graph)', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant->users()->attach($user);
|
||||||
|
|
||||||
|
$run = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'type' => 'test.run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'run_identity_hash' => 'hash123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows runs only for current tenant', function () {
|
||||||
|
$tenantA = Tenant::factory()->create();
|
||||||
|
$tenantB = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenantA->users()->attach($user);
|
||||||
|
|
||||||
|
// We must simulate being in tenant context
|
||||||
|
$this->actingAs($user);
|
||||||
|
// Filament::setTenant($tenantA); // This is usually handled by middleware on routes, but in Livewire test we might need manual set or route visit.
|
||||||
|
|
||||||
|
// Easier approach: visit the page for tenantA
|
||||||
|
|
||||||
|
OperationRun::create([
|
||||||
|
'tenant_id' => $tenantA->id,
|
||||||
|
'type' => 'tenantA.run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'run_identity_hash' => 'hashA',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::create([
|
||||||
|
'tenant_id' => $tenantB->id,
|
||||||
|
'type' => 'tenantB.run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'run_identity_hash' => 'hashB',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Livewire::test needs to know the tenant if the component relies on it.
|
||||||
|
// However, the component relies on `Filament::getTenant()`.
|
||||||
|
// The cleanest way is to just GET the page URL, which runs middleware.
|
||||||
|
|
||||||
|
$this->get(OperationRunResource::getUrl('index', tenant: $tenantA))
|
||||||
|
->assertSee('tenantA.run')
|
||||||
|
->assertDontSee('tenantB.run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows readonly users to view operations list and detail', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$tenant->users()->attach($user, ['role' => 'readonly']);
|
||||||
|
|
||||||
|
$run = OperationRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'type' => 'test.run',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'initiator_name' => 'System',
|
||||||
|
'run_identity_hash' => 'hash123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('index', tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('test.run');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('test.run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies access to unauthorized users', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
// Not attached to tenant
|
||||||
|
|
||||||
|
// In a multitenant app, if you try to access a tenant route you are not part of,
|
||||||
|
// Filament typically returns 404 (Not Found) if it can't find the tenant-user relationship, or 403.
|
||||||
|
// The previous fail said "Received 404". This confirms Filament couldn't find the tenant for this user scope or just hides it.
|
||||||
|
// We should accept 404 or 403.
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->get(OperationRunResource::getUrl('index', tenant: $tenant));
|
||||||
|
|
||||||
|
// Allow either 403 or 404 as "Denied"
|
||||||
|
$this->assertTrue(in_array($response->status(), [403, 404]));
|
||||||
|
});
|
||||||
131
tests/Feature/Notifications/OperationRunNotificationTest.php
Normal file
131
tests/Feature/Notifications/OperationRunNotificationTest.php
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Notifications\OperationRunCompleted;
|
||||||
|
use App\Notifications\OperationRunQueued;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$service = app(OperationRunService::class);
|
||||||
|
$run = $service->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: ['scope' => 'all'],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$service->dispatchOrFail($run, function (): void {
|
||||||
|
// no-op (dispatch succeeded)
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
'notifiable_id' => $user->getKey(),
|
||||||
|
'notifiable_type' => $user->getMorphClass(),
|
||||||
|
'type' => OperationRunQueued::class,
|
||||||
|
'data->format' => 'filament',
|
||||||
|
'data->title' => 'Operation queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit queued notifications for runs without an initiator', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $service->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: ['scope' => 'all'],
|
||||||
|
initiator: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$service->dispatchOrFail($run, function (): void {
|
||||||
|
// no-op
|
||||||
|
});
|
||||||
|
|
||||||
|
expect($user->notifications()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a terminal notification when an operation run transitions to completed', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'inventory.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'context' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$service->updateRun(
|
||||||
|
$run,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
summaryCounts: ['observed' => 1],
|
||||||
|
failures: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
'notifiable_id' => $user->getKey(),
|
||||||
|
'notifiable_type' => $user->getMorphClass(),
|
||||||
|
'type' => OperationRunCompleted::class,
|
||||||
|
'data->format' => 'filament',
|
||||||
|
'data->title' => 'Operation completed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
|
expect($notification)->not->toBeNull();
|
||||||
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a run failed if dispatch throws synchronously', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
|
$run = $service->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: ['scope' => 'all'],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fn () => $service->dispatchOrFail($run, function (): void {
|
||||||
|
throw new RuntimeException('Queue misconfigured');
|
||||||
|
}))
|
||||||
|
->toThrow(RuntimeException::class);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
expect($run->status)->toBe('completed');
|
||||||
|
expect($run->outcome)->toBe('failed');
|
||||||
|
});
|
||||||
171
tests/Feature/OperationRunServiceTest.php
Normal file
171
tests/Feature/OperationRunServiceTest.php
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
|
||||||
|
it('creates a new operation run', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user);
|
||||||
|
|
||||||
|
expect($run)->toBeInstanceOf(OperationRun::class);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('operation_runs', [
|
||||||
|
'id' => $run->getKey(),
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'test.action',
|
||||||
|
'status' => 'queued',
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an active run (idempotent)', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||||
|
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||||
|
|
||||||
|
expect($runA->getKey())->toBe($runB->getKey());
|
||||||
|
expect(OperationRun::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not replace the initiator when deduping', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$userA = User::factory()->create();
|
||||||
|
$userB = User::factory()->create();
|
||||||
|
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA);
|
||||||
|
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB);
|
||||||
|
|
||||||
|
expect($runA->getKey())->toBe($runB->getKey());
|
||||||
|
expect($runB->fresh()?->user_id)->toBe($userA->getKey());
|
||||||
|
expect($runB->fresh()?->initiator_name)->toBe($userA->name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes inputs deterministically regardless of key order', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]);
|
||||||
|
$runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]);
|
||||||
|
|
||||||
|
expect($runA->getKey())->toBe($runB->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hashes list inputs deterministically regardless of list order', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]);
|
||||||
|
$runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]);
|
||||||
|
|
||||||
|
expect($runA->getKey())->toBe($runB->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unique-index race collisions by returning the active run', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$fired = false;
|
||||||
|
|
||||||
|
$dispatcher = OperationRun::getEventDispatcher();
|
||||||
|
|
||||||
|
OperationRun::creating(function (OperationRun $model) use (&$fired): void {
|
||||||
|
if ($fired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fired = true;
|
||||||
|
|
||||||
|
OperationRun::withoutEvents(function () use ($model): void {
|
||||||
|
OperationRun::query()->create([
|
||||||
|
'tenant_id' => $model->tenant_id,
|
||||||
|
'user_id' => $model->user_id,
|
||||||
|
'initiator_name' => $model->initiator_name,
|
||||||
|
'type' => $model->type,
|
||||||
|
'status' => $model->status,
|
||||||
|
'outcome' => $model->outcome,
|
||||||
|
'run_identity_hash' => $model->run_identity_hash,
|
||||||
|
'context' => $model->context,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
$run = $service->ensureRun($tenant, 'test.race', ['scope' => 'full']);
|
||||||
|
} finally {
|
||||||
|
OperationRun::flushEventListeners();
|
||||||
|
OperationRun::setEventDispatcher($dispatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($run)->toBeInstanceOf(OperationRun::class);
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->where('type', 'test.race')->count())
|
||||||
|
->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new run after the previous one completed', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||||
|
$runA->update(['status' => 'completed']);
|
||||||
|
|
||||||
|
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||||
|
|
||||||
|
expect($runA->getKey())->not->toBe($runB->getKey());
|
||||||
|
expect(OperationRun::query()->count())->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates run lifecycle fields and summaries', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$run = $service->ensureRun($tenant, 'test.action', []);
|
||||||
|
|
||||||
|
$service->updateRun($run, 'running');
|
||||||
|
|
||||||
|
$fresh = $run->fresh();
|
||||||
|
expect($fresh?->status)->toBe('running');
|
||||||
|
expect($fresh?->started_at)->not->toBeNull();
|
||||||
|
|
||||||
|
$service->updateRun($run, 'completed', 'succeeded', ['success' => 1]);
|
||||||
|
|
||||||
|
$fresh = $run->fresh();
|
||||||
|
expect($fresh?->status)->toBe('completed');
|
||||||
|
expect($fresh?->outcome)->toBe('succeeded');
|
||||||
|
expect($fresh?->completed_at)->not->toBeNull();
|
||||||
|
expect($fresh?->summary_counts)->toBe(['success' => 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes failure messages and redacts obvious secrets', function () {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$service = new OperationRunService;
|
||||||
|
|
||||||
|
$run = $service->ensureRun($tenant, 'test.action', []);
|
||||||
|
|
||||||
|
try {
|
||||||
|
throw new RuntimeException('Authorization: Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$service->failRun($run, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fresh = $run->fresh();
|
||||||
|
expect($fresh?->status)->toBe('completed');
|
||||||
|
expect($fresh?->outcome)->toBe('failed');
|
||||||
|
expect($fresh?->failure_summary)->toBeArray();
|
||||||
|
|
||||||
|
$message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
|
||||||
|
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
|
||||||
|
expect($message)->toContain('[REDACTED]');
|
||||||
|
});
|
||||||
181
tests/Feature/PolicySyncStartSurfaceTest.php
Normal file
181
tests/Feature/PolicySyncStartSurfaceTest.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
it('queues policy sync and creates a canonical operation run (no Graph calls in request)', function () {
|
||||||
|
Queue::fake();
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$requestedTypes = array_map(
|
||||||
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
|
config('tenantpilot.supported_policy_types', [])
|
||||||
|
);
|
||||||
|
|
||||||
|
sort($requestedTypes);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'policy.sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context)->toMatchArray([
|
||||||
|
'scope' => 'all',
|
||||||
|
'types' => $requestedTypes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $requestedTypes): bool {
|
||||||
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->types === $requestedTypes
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an active policy sync run and does not enqueue twice', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction();
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction();
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class, 1);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'policy.sync')
|
||||||
|
->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues row policy sync and creates a canonical operation run (no Graph calls in request)', function () {
|
||||||
|
Queue::fake();
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'ignored_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->callTableAction('sync', $policy)
|
||||||
|
->assertHasNoTableActionErrors();
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'policy.sync_one')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context)->toMatchArray([
|
||||||
|
'scope' => 'one',
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $policy): bool {
|
||||||
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->types === null
|
||||||
|
&& $job->policyIds === [(int) $policy->getKey()]
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues bulk policy sync and creates a canonical operation run (no Graph calls in request)', function () {
|
||||||
|
Queue::fake();
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$policies = Policy::factory()->count(2)->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'ignored_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->callTableBulkAction('bulk_sync', $policies)
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$selectedIds = $policies
|
||||||
|
->pluck('id')
|
||||||
|
->map(static fn ($id): int => (int) $id)
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'policy.sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context)->toMatchArray([
|
||||||
|
'scope' => 'subset',
|
||||||
|
'policy_ids' => $selectedIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class, function (SyncPoliciesJob $job) use ($tenant, $run, $selectedIds): bool {
|
||||||
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->types === null
|
||||||
|
&& $job->policyIds === $selectedIds
|
||||||
|
&& $job->operationRun instanceof OperationRun
|
||||||
|
&& (int) $job->operationRun->getKey() === (int) $run?->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides policy sync start action for readonly users', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->assertActionHidden('sync');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
93
tests/Feature/RestoreAdapterTest.php
Normal file
93
tests/Feature/RestoreAdapterTest.php
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
|
||||||
|
it('creates an operation run only from previewed onward', function () {
|
||||||
|
$restoreRun = RestoreRun::factory()->create([
|
||||||
|
'status' => RestoreRunStatus::Checked->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', $restoreRun->tenant_id)
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
$restoreRun->update(['status' => RestoreRunStatus::Previewed->value]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $restoreRun->tenant_id)
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
expect($opRun?->status)->toBe('queued');
|
||||||
|
expect($opRun?->outcome)->toBe('pending');
|
||||||
|
expect($opRun?->context)->toMatchArray([
|
||||||
|
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||||
|
'backup_set_id' => (int) $restoreRun->backup_set_id,
|
||||||
|
'is_dry_run' => (bool) $restoreRun->is_dry_run,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the operation run when restore completes', function () {
|
||||||
|
$restoreRun = RestoreRun::factory()->create([
|
||||||
|
'status' => RestoreRunStatus::Previewed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $restoreRun->tenant_id)
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
expect($opRun?->status)->toBe('queued');
|
||||||
|
|
||||||
|
$restoreRun->update([
|
||||||
|
'status' => RestoreRunStatus::Completed->value,
|
||||||
|
'results' => [
|
||||||
|
'assignment_outcomes' => [
|
||||||
|
['status' => 'success'],
|
||||||
|
['status' => 'failed'],
|
||||||
|
['status' => 'skipped'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opRun->refresh();
|
||||||
|
|
||||||
|
expect($opRun->status)->toBe('completed');
|
||||||
|
expect($opRun->outcome)->toBe('succeeded');
|
||||||
|
expect($opRun->summary_counts)->toMatchArray([
|
||||||
|
'assignments_success' => 1,
|
||||||
|
'assignments_failed' => 1,
|
||||||
|
'assignments_skipped' => 1,
|
||||||
|
]);
|
||||||
|
expect($opRun->completed_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps cancelled restore runs to failed outcome (cancelled is reserved)', function () {
|
||||||
|
$restoreRun = RestoreRun::factory()->create([
|
||||||
|
'status' => RestoreRunStatus::Previewed->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$opRun = OperationRun::query()
|
||||||
|
->where('tenant_id', $restoreRun->tenant_id)
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($opRun)->not->toBeNull();
|
||||||
|
|
||||||
|
$restoreRun->update(['status' => RestoreRunStatus::Cancelled->value]);
|
||||||
|
|
||||||
|
$opRun->refresh();
|
||||||
|
|
||||||
|
expect($opRun->status)->toBe('completed');
|
||||||
|
expect($opRun->outcome)->toBe('failed');
|
||||||
|
expect($opRun->failure_summary)->toBeArray();
|
||||||
|
expect($opRun->failure_summary[0]['code'] ?? null)->toBe('restore.cancelled');
|
||||||
|
});
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\PruneOldOperationRunsJob;
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
|
||||||
|
it('schedules pruning job daily without overlapping', function () {
|
||||||
|
/** @var Schedule $schedule */
|
||||||
|
$schedule = app(Schedule::class);
|
||||||
|
|
||||||
|
$event = collect($schedule->events())
|
||||||
|
->first(fn ($event) => ($event->description ?? null) === PruneOldOperationRunsJob::class);
|
||||||
|
|
||||||
|
expect($event)->not->toBeNull();
|
||||||
|
expect($event->withoutOverlapping)->toBeTrue();
|
||||||
|
});
|
||||||
43
tests/Feature/TrackOperationRunMiddlewareTest.php
Normal file
43
tests/Feature/TrackOperationRunMiddlewareTest.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
it('does not mark an operation run completed when the job is released', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
/** @var OperationRunService $operationRunService */
|
||||||
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
$operationRun = $operationRunService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'test.release',
|
||||||
|
inputs: ['foo' => 'bar'],
|
||||||
|
initiator: $user,
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new class($operationRun) implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public OperationRun $operationRun)
|
||||||
|
{
|
||||||
|
$this->withFakeQueueInteractions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$middleware = new TrackOperationRun;
|
||||||
|
|
||||||
|
$middleware->handle($job, function ($job): void {
|
||||||
|
$job->release(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
$operationRun->refresh();
|
||||||
|
expect($operationRun->status)->toBe('running');
|
||||||
|
expect($operationRun->outcome)->toBe('pending');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user