feat/012-windows-update-rings #18

Merged
ahmido merged 24 commits from feat/012-windows-update-rings into dev 2026-01-01 10:44:18 +00:00
9 changed files with 444 additions and 66 deletions
Showing only changes of commit 3cf4aa2cf4 - Show all commits

View File

@ -26,12 +26,14 @@
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Contracts\HasTable; use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
@ -69,8 +71,10 @@ public static function form(Schema $schema): Schema
}) })
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void { ->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []); $set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []); $set('group_mapping', []);
$set('is_dry_run', true);
}) })
->required(), ->required(),
Forms\Components\CheckboxList::make('backup_item_ids') Forms\Components\CheckboxList::make('backup_item_ids')
@ -137,6 +141,164 @@ public static function form(Schema $schema): Schema
]); ]);
} }
/**
* @return array<int, Step>
*/
public static function getWizardSteps(): array
{
return [
Step::make('Select Backup Set')
->description('What are we restoring from?')
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []);
$set('is_dry_run', true);
})
->required(),
]),
Step::make('Define Restore Scope')
->description('What exactly should be restored?')
->schema([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
->options([
'all' => 'All items (default)',
'selected' => 'Selected items only',
])
->default('all')
->reactive()
->afterStateUpdated(function (Set $set, $state): void {
$set('group_mapping', []);
if ($state === 'all') {
$set('backup_item_ids', null);
}
})
->required(),
Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore')
->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options'])
->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions'])
->columns(1)
->searchable()
->bulkToggleable()
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->helperText('Search by name, type, or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'),
Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
);
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
$groupId = $group['id'];
$label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}")
->label($label)
->options([
'SKIP' => 'Skip assignment',
])
->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
->helperText('Choose a target group or select Skip.');
}, $unresolved);
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return false;
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
) !== [];
}),
]),
Step::make('Safety & Conflict Checks')
->description('Defensive checks (Phase 4)')
->schema([
Forms\Components\Placeholder::make('safety_checks_placeholder')
->label('Status')
->content('Safety & conflict checks will be added in Phase 4.'),
]),
Step::make('Preview')
->description('Dry-run preview (Phase 5)')
->schema([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
Forms\Components\Placeholder::make('preview_placeholder')
->label('Preview')
->content('Preview diff summary will be added in Phase 5.'),
]),
Step::make('Confirm & Execute')
->description('Explicit confirmations (Phase 6)')
->schema([
Forms\Components\Placeholder::make('confirm_placeholder')
->label('Execution')
->content('Execution confirmations and gating will be added in Phase 6.'),
]),
];
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -533,13 +695,9 @@ private static function restoreItemOptionData(?int $backupSetId): array
]; ];
} }
static $cache = []; $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
$cacheKey = $tenant->getKey().':'.$backupSetId;
if (isset($cache[$cacheKey])) {
return $cache[$cacheKey];
}
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
$items = BackupItem::query() $items = BackupItem::query()
->where('backup_set_id', $backupSetId) ->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
@ -587,10 +745,11 @@ private static function restoreItemOptionData(?int $backupSetId): array
$descriptions[$item->id] = implode(' • ', $parts); $descriptions[$item->id] = implode(' • ', $parts);
} }
return $cache[$cacheKey] = [ return [
'options' => $options, 'options' => $options,
'descriptions' => $descriptions, 'descriptions' => $descriptions,
]; ];
});
} }
public static function createRestoreRun(array $data): RestoreRun public static function createRestoreRun(array $data): RestoreRun
@ -608,10 +767,15 @@ public static function createRestoreRun(array $data): RestoreRun
/** @var RestoreService $service */ /** @var RestoreService $service */
$service = app(RestoreService::class); $service = app(RestoreService::class);
$scopeMode = $data['scope_mode'] ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($data['backup_item_ids'] ?? null)
: null;
return $service->execute( return $service->execute(
tenant: $tenant, tenant: $tenant,
backupSet: $backupSet, backupSet: $backupSet,
selectedItemIds: $data['backup_item_ids'] ?? null, selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
dryRun: (bool) ($data['is_dry_run'] ?? true), dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email, actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name, actorName: auth()->user()?->name,

View File

@ -3,13 +3,21 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
{ {
use HasWizard;
protected static string $resource = RestoreRunResource::class; protected static string $resource = RestoreRunResource::class;
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();
}
protected function handleRecordCreation(array $data): Model protected function handleRecordCreation(array $data): Model
{ {
return RestoreRunResource::createRestoreRun($data); return RestoreRunResource::createRestoreRun($data);

View File

@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use App\Support\RestoreRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -35,17 +37,30 @@ public function backupSet(): BelongsTo
return $this->belongsTo(BackupSet::class)->withTrashed(); return $this->belongsTo(BackupSet::class)->withTrashed();
} }
public function scopeDeletable($query) public function scopeDeletable(Builder $query): Builder
{ {
return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); return $query->whereIn('status', array_map(
static fn (RestoreRunStatus $status): string => $status->value,
[
RestoreRunStatus::Draft,
RestoreRunStatus::Scoped,
RestoreRunStatus::Checked,
RestoreRunStatus::Previewed,
RestoreRunStatus::Completed,
RestoreRunStatus::Partial,
RestoreRunStatus::Failed,
RestoreRunStatus::Cancelled,
RestoreRunStatus::Aborted,
RestoreRunStatus::CompletedWithErrors,
]
));
} }
public function isDeletable(): bool public function isDeletable(): bool
{ {
$status = strtolower(trim((string) $this->status)); $status = RestoreRunStatus::fromString($this->status);
$status = str_replace([' ', '-'], '_', $status);
return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); return $status?->isDeletable() ?? false;
} }
// Group mapping helpers // Group mapping helpers

View File

@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
{ {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
@ -191,11 +195,21 @@ public function execute(
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$items = $this->loadItems($backupSet, $selectedItemIds); $items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items); [$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds); $preview = $this->preview($tenant, $backupSet, $selectedItemIds);
$wizardMetadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
];
$restoreRun = RestoreRun::create([ $restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
@ -205,7 +219,7 @@ public function execute(
'requested_items' => $selectedItemIds, 'requested_items' => $selectedItemIds,
'preview' => $preview, 'preview' => $preview,
'started_at' => CarbonImmutable::now(), 'started_at' => CarbonImmutable::now(),
'metadata' => [], 'metadata' => $wizardMetadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null, 'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]); ]);
@ -740,12 +754,12 @@ public function execute(
'status' => $status, 'status' => $status,
'results' => $results, 'results' => $results,
'completed_at' => CarbonImmutable::now(), 'completed_at' => CarbonImmutable::now(),
'metadata' => [ 'metadata' => array_merge($restoreRun->metadata ?? [], [
'failed' => $hardFailures, 'failed' => $hardFailures,
'non_applied' => $nonApplied, 'non_applied' => $nonApplied,
'total' => $totalCount, 'total' => $totalCount,
'foundations_skipped' => $foundationSkipped, 'foundations_skipped' => $foundationSkipped,
], ]),
]); ]);
$this->auditLogger->log( $this->auditLogger->log(

View File

@ -0,0 +1,73 @@
<?php
namespace App\Support;
enum RestoreRunStatus: string
{
case Draft = 'draft';
case Scoped = 'scoped';
case Checked = 'checked';
case Previewed = 'previewed';
case Pending = 'pending';
case Queued = 'queued';
case Running = 'running';
case Completed = 'completed';
case Partial = 'partial';
case Failed = 'failed';
case Cancelled = 'cancelled';
// Legacy / compatibility statuses (existing housekeeping semantics)
case Aborted = 'aborted';
case CompletedWithErrors = 'completed_with_errors';
public static function fromString(?string $value): ?self
{
if ($value === null) {
return null;
}
$normalized = strtolower(trim($value));
$normalized = str_replace([' ', '-'], '_', $normalized);
return self::tryFrom($normalized);
}
public function canTransitionTo(self $next): bool
{
if ($this === $next) {
return true;
}
return match ($this) {
self::Draft => in_array($next, [self::Scoped, self::Cancelled], true),
self::Scoped => in_array($next, [self::Checked, self::Cancelled], true),
self::Checked => in_array($next, [self::Previewed, self::Cancelled], true),
self::Previewed => in_array($next, [self::Queued, self::Cancelled], true),
self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true),
self::Queued => in_array($next, [self::Running, self::Cancelled], true),
self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true),
self::Completed,
self::Partial,
self::Failed,
self::Cancelled,
self::Aborted,
self::CompletedWithErrors => false,
};
}
public function isDeletable(): bool
{
return in_array($this, [
self::Draft,
self::Scoped,
self::Checked,
self::Previewed,
self::Completed,
self::Partial,
self::Failed,
self::Cancelled,
self::Aborted,
self::CompletedWithErrors,
], true);
}
}

View File

@ -7,13 +7,13 @@ ## Phase 0 — Specs (this PR)
- [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011.
## Phase 1 — Data Model + Status Semantics ## Phase 1 — Data Model + Status Semantics
- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). - [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed).
- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). - [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required).
- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. - [x] T004 Freeze `environment` + `highlander_label` at run creation for audit.
## Phase 2 — Filament Wizard (Create Restore Run) ## Phase 2 — Filament Wizard (Create Restore Run)
- [ ] T005 Replace current single-form create with a 5-step wizard (Step 15 as in spec). - [x] T005 Replace current single-form create with a 5-step wizard (Step 15 as in spec).
- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. - [x] T006 Ensure changing `backup_set_id` resets downstream wizard state.
- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. - [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied.
## Phase 3 — Restore Scope UX ## Phase 3 — Restore Scope UX
@ -40,4 +40,3 @@ ## Phase 7 — Tests + Formatting
- [ ] T020 Add Pest tests for preview summary generation. - [ ] T020 Add Pest tests for preview summary generation.
- [ ] T021 Run `./vendor/bin/pint --dirty`. - [ ] T021 Run `./vendor/bin/pint --dirty`.
- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). - [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist).

View File

@ -84,6 +84,10 @@
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
]) ])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
])
->assertSee('Policy Display') ->assertSee('Policy Display')
->assertDontSee('Ignored Policy') ->assertDontSee('Ignored Policy')
->assertSee('Scope Tag Alpha') ->assertSee('Scope Tag Alpha')

View File

@ -80,6 +80,10 @@
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id], 'backup_item_ids' => [$backupItem->id],
]) ])
->assertFormFieldVisible('group_mapping.source-group-1'); ->assertFormFieldVisible('group_mapping.source-group-1');
@ -150,12 +154,21 @@
Livewire::test(CreateRestoreRun::class) Livewire::test(CreateRestoreRun::class)
->fillForm([ ->fillForm([
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id], 'backup_item_ids' => [$backupItem->id],
'group_mapping' => [ 'group_mapping' => [
'source-group-1' => 'target-group-1', 'source-group-1' => 'target-group-1',
], ],
])
->goToNextWizardStep()
->goToNextWizardStep()
->fillForm([
'is_dry_run' => true, 'is_dry_run' => true,
]) ])
->goToNextWizardStep()
->call('create') ->call('create')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();

View File

@ -0,0 +1,88 @@
<?php
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
putenv('INTUNE_TENANT_ID');
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
});
test('restore run stores wizard audit metadata and preserves it on completion', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$tenant->makeCurrent();
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => null,
'policy_identifier' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'payload' => ['id' => 'policy-1'],
'metadata' => [
'displayName' => 'Backup Policy One',
],
]);
$user = User::factory()->create([
'email' => 'tester@example.com',
'name' => 'Tester',
]);
$this->actingAs($user);
Livewire::test(CreateRestoreRun::class)
->fillForm([
'backup_set_id' => $backupSet->id,
])
->goToNextWizardStep()
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
])
->goToNextWizardStep()
->goToNextWizardStep()
->fillForm([
'is_dry_run' => true,
])
->goToNextWizardStep()
->call('create')
->assertHasNoFormErrors();
$run = RestoreRun::query()->latest('id')->first();
expect($run)->not->toBeNull();
expect($run->metadata)->toHaveKeys([
'scope_mode',
'environment',
'highlander_label',
'failed',
'non_applied',
'total',
'foundations_skipped',
]);
expect($run->metadata['scope_mode'])->toBe('selected');
expect($run->metadata['environment'])->toBe('test');
expect($run->metadata['highlander_label'])->toBe('Tenant One');
});