Kurzbeschreibung Versteckt die Rerun-Row-Action für archivierte (soft-deleted) RestoreRuns und verhindert damit fehlerhafte Neu-Starts aus dem Archiv; ergänzt einen Regressionstest. Änderungen Code: RestoreRunResource.php — Sichtbarkeit der rerun-Action geprüft auf ! $record->trashed() und defensive Abbruchprüfung im Action-Handler. Tests: RestoreRunRerunTest.php — neuer Test rerun action is hidden for archived restore runs. Warum Archivierte RestoreRuns durften nicht neu gestartet werden; UI zeigte trotzdem die Option. Das führte zu verwirrendem Verhalten und möglichen Fehlern beim Enqueueing. Verifikation / QA Unit/Feature: ./vendor/bin/sail artisan test tests/Feature/RestoreRunRerunTest.php Stil/format: ./vendor/bin/pint --dirty Manuell (UI): Als Tenant-Admin Filament → Restore Runs öffnen. Filter Archived aktivieren (oder Trashed filter auswählen). Sicherstellen, dass für archivierte Einträge die Rerun-Action nicht sichtbar ist. Auf einem aktiven (nicht-archivierten) Run prüfen, dass Rerun sichtbar bleibt und wie erwartet eine neue RestoreRun erzeugt. Wichtige Hinweise Kein DB-Migration required. Diese PR enthält nur den UI-/Filament-Fix; die zuvor gemachten operative Fixes für Queue/adapter-Reconciliation bleiben ebenfalls auf dem Branch (z. B. frühere commits während der Debugging-Session). T055 (Schema squash) wurde bewusst zurückgestellt und ist nicht Teil dieses PRs. Merge-Checklist Tests lokal laufen (RestoreRunRerunTest grünt) Pint läuft ohne ungepatchte Fehler Branch gepusht: 056-remove-legacy-bulkops (PR-URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...056-remove-legacy-bulkops) Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #65
312 lines
10 KiB
PHP
312 lines
10 KiB
PHP
<?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\Intune\AuditLogger;
|
|
use App\Services\OperationRunService;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OpsUx\RunFailureSanitizer;
|
|
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,
|
|
): 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',
|
|
['failed' => 1],
|
|
[['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' => RunFailureSanitizer::sanitizeMessage("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);
|
|
|
|
$this->operationRun->update([
|
|
'context' => array_merge($this->operationRun->context ?? [], [
|
|
'backup_set_id' => (int) $backupSet->getKey(),
|
|
'requested_count' => $requestedCount,
|
|
'removed_count' => $removed,
|
|
'missing_count' => count($missingIds),
|
|
'remaining_count' => (int) $backupSet->item_count,
|
|
]),
|
|
]);
|
|
|
|
$outcome = 'succeeded';
|
|
if ($removed === 0) {
|
|
$outcome = 'failed';
|
|
} elseif ($failures !== []) {
|
|
$outcome = 'partially_succeeded';
|
|
}
|
|
|
|
$opService->updateRun(
|
|
$this->operationRun,
|
|
'completed',
|
|
$outcome,
|
|
[
|
|
'total' => $requestedCount,
|
|
'processed' => $requestedCount,
|
|
'succeeded' => $removed,
|
|
'failed' => count($missingIds),
|
|
'deleted' => $removed,
|
|
'items' => $requestedCount,
|
|
],
|
|
$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: RunFailureSanitizer::sanitizeMessage($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();
|
|
}
|
|
}
|