merge: agent session work
This commit is contained in:
commit
0b873b4745
5
.gitignore
vendored
5
.gitignore
vendored
@ -13,6 +13,9 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
@ -23,3 +26,5 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
29
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\BackupScheduling\BackupScheduleDispatcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotDispatchBackupSchedules extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:schedules:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch due backup schedules (idempotent per schedule minute-slot).';
|
||||
|
||||
public function handle(BackupScheduleDispatcher $dispatcher): int
|
||||
{
|
||||
$tenantIdentifiers = (array) $this->option('tenant');
|
||||
|
||||
$result = $dispatcher->dispatchDue($tenantIdentifiers);
|
||||
|
||||
$this->info(sprintf(
|
||||
'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).',
|
||||
$result['scanned_schedules'],
|
||||
$result['created_runs'],
|
||||
$result['skipped_runs'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
17
app/Exceptions/InvalidPolicyTypeException.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidPolicyTypeException extends RuntimeException
|
||||
{
|
||||
public array $unknownPolicyTypes;
|
||||
|
||||
public function __construct(array $unknownPolicyTypes)
|
||||
{
|
||||
$this->unknownPolicyTypes = array_values($unknownPolicyTypes);
|
||||
|
||||
parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes));
|
||||
}
|
||||
}
|
||||
737
app/Filament/Resources/BackupScheduleResource.php
Normal file
737
app/Filament/Resources/BackupScheduleResource.php
Normal file
@ -0,0 +1,737 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BackupScheduleRunDispatchedNotification;
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\TenantRole;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeZone;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
protected static function currentTenantRole(): ?TenantRole
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user->tenantRole(Tenant::current());
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
return static::currentTenantRole() !== null;
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
return static::currentTenantRole() !== null;
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Schedule Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
|
||||
Select::make('timezone')
|
||||
->label('Timezone')
|
||||
->options(static::timezoneOptions())
|
||||
->searchable()
|
||||
->default('UTC')
|
||||
->required(),
|
||||
|
||||
Select::make('frequency')
|
||||
->label('Frequency')
|
||||
->options([
|
||||
'daily' => 'Daily',
|
||||
'weekly' => 'Weekly',
|
||||
])
|
||||
->default('daily')
|
||||
->required()
|
||||
->reactive(),
|
||||
|
||||
TextInput::make('time_of_day')
|
||||
->label('Time of day')
|
||||
->type('time')
|
||||
->required()
|
||||
->extraInputAttributes(['step' => 60]),
|
||||
|
||||
CheckboxList::make('days_of_week')
|
||||
->label('Days of the week')
|
||||
->options(static::dayOfWeekOptions())
|
||||
->columns(2)
|
||||
->visible(fn (Get $get): bool => $get('frequency') === 'weekly')
|
||||
->required(fn (Get $get): bool => $get('frequency') === 'weekly')
|
||||
->rules(['array', 'min:1']),
|
||||
|
||||
CheckboxList::make('policy_types')
|
||||
->label('Policy types')
|
||||
->options(static::policyTypeOptions())
|
||||
->columns(2)
|
||||
->required()
|
||||
->helperText('Select the Microsoft Graph policy types that should be included in each run.')
|
||||
->rules([
|
||||
'array',
|
||||
'min:1',
|
||||
new SupportedPolicyTypesRule,
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('include_foundations')
|
||||
->label('Include foundation types')
|
||||
->default(true),
|
||||
|
||||
TextInput::make('retention_keep_last')
|
||||
->label('Retention (keep last N Backup Sets)')
|
||||
->type('number')
|
||||
->default(30)
|
||||
->minValue(1)
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('next_run_at', 'asc')
|
||||
->columns([
|
||||
IconColumn::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->boolean()
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->label('Schedule'),
|
||||
|
||||
TextColumn::make('frequency')
|
||||
->label('Frequency')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'daily' => 'Daily',
|
||||
'weekly' => 'Weekly',
|
||||
default => (string) $state,
|
||||
})
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'daily' => 'success',
|
||||
'weekly' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('time_of_day')
|
||||
->label('Time')
|
||||
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
|
||||
|
||||
TextColumn::make('timezone')
|
||||
->label('Timezone'),
|
||||
|
||||
TextColumn::make('policy_types')
|
||||
->label('Policy types')
|
||||
->wrap()
|
||||
->getStateUsing(function (BackupSchedule $record): string {
|
||||
$state = $record->policy_types;
|
||||
|
||||
if (is_string($state)) {
|
||||
$decoded = json_decode($state, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$state = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($state instanceof \Illuminate\Contracts\Support\Arrayable) {
|
||||
$state = $state->toArray();
|
||||
}
|
||||
|
||||
if (! is_array($state)) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$types = array_is_list($state)
|
||||
? $state
|
||||
: array_keys(array_filter($state));
|
||||
|
||||
$types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== ''));
|
||||
|
||||
if ($types === []) {
|
||||
return 'None';
|
||||
}
|
||||
|
||||
$labelMap = collect(config('tenantpilot.supported_policy_types', []))
|
||||
->mapWithKeys(fn (array $policy): array => [
|
||||
(string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))),
|
||||
])
|
||||
->filter(fn (string $label, string $type): bool => $type !== '')
|
||||
->all();
|
||||
|
||||
$labels = array_map(
|
||||
fn (string $type): string => $labelMap[$type] ?? Str::headline($type),
|
||||
$types,
|
||||
);
|
||||
|
||||
return implode(', ', $labels);
|
||||
}),
|
||||
|
||||
TextColumn::make('retention_keep_last')
|
||||
->label('Retention')
|
||||
->suffix(' sets'),
|
||||
|
||||
TextColumn::make('last_run_status')
|
||||
->label('Last run status')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
BackupScheduleRun::STATUS_RUNNING => 'Running',
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'Success',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'Partial',
|
||||
BackupScheduleRun::STATUS_FAILED => 'Failed',
|
||||
BackupScheduleRun::STATUS_CANCELED => 'Canceled',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'Skipped',
|
||||
default => $state ? Str::headline($state) : '—',
|
||||
})
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'success',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'warning',
|
||||
BackupScheduleRun::STATUS_RUNNING => 'primary',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'gray',
|
||||
BackupScheduleRun::STATUS_FAILED,
|
||||
BackupScheduleRun::STATUS_CANCELED => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('last_run_at')
|
||||
->label('Last run')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('next_run_at')
|
||||
->label('Next run')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('enabled_state')
|
||||
->label('Enabled')
|
||||
->options([
|
||||
'enabled' => 'Enabled',
|
||||
'disabled' => 'Disabled',
|
||||
])
|
||||
->query(function (Builder $query, array $data): void {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (blank($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'enabled') {
|
||||
$query->where('is_enabled', true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($value === 'disabled') {
|
||||
$query->where('is_enabled', false);
|
||||
}
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
ActionGroup::make([
|
||||
Action::make('runNow')
|
||||
->label('Run now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
||||
->action(function (BackupSchedule $record): void {
|
||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'run_now',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_schedule_id' => (int) $record->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'run_now',
|
||||
]));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Run dispatched')
|
||||
->body('The backup run has been queued.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Action::make('retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
||||
->action(function (BackupSchedule $record): void {
|
||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'retry',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'backup_schedule_id' => (int) $record->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'retry',
|
||||
]));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Retry dispatched')
|
||||
->body('A new backup run has been queued.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
EditAction::make()
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||
DeleteAction::make()
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('bulk_run_now')
|
||||
->label('Run now')
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
||||
->action(function (Collection $records): void {
|
||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
}
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
'backup_schedule_run_ids' => $createdRunIds,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
]));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Runs dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
BulkAction::make('bulk_retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
||||
->action(function (Collection $records): void {
|
||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
/** @var BackupSchedule $record */
|
||||
foreach ($records as $record) {
|
||||
$scheduledFor = CarbonImmutable::now('UTC')->startOfMinute();
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$exists = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $record->id)
|
||||
->where('scheduled_for', $scheduledFor)
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
break;
|
||||
}
|
||||
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $record->id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
$createdRunIds[] = (int) $run->id;
|
||||
|
||||
app(AuditLogger::class)->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_dispatched_manual',
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $record->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_retry',
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
}
|
||||
|
||||
if ($user instanceof User) {
|
||||
$user->notify(new BackupScheduleRunDispatchedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(),
|
||||
'backup_schedule_run_ids' => $createdRunIds,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'trigger' => 'bulk_retry',
|
||||
]));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Retries dispatched')
|
||||
->body(sprintf('Queued %d run(s).', count($createdRunIds)))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
DeleteBulkAction::make('bulk_delete')
|
||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('is_enabled')
|
||||
->orderBy('next_run_at');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BackupScheduleRunsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBackupSchedules::route('/'),
|
||||
'create' => Pages\CreateBackupSchedule::route('/create'),
|
||||
'edit' => Pages\EditBackupSchedule::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function ensurePolicyTypes(array $data): array
|
||||
{
|
||||
$types = array_values((array) ($data['policy_types'] ?? []));
|
||||
|
||||
try {
|
||||
app(PolicyTypeResolver::class)->ensureSupported($types);
|
||||
} catch (InvalidPolicyTypeException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))],
|
||||
]);
|
||||
}
|
||||
|
||||
$data['policy_types'] = $types;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = Tenant::current()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function hydrateNextRun(array $data): array
|
||||
{
|
||||
if (! empty($data['time_of_day'])) {
|
||||
$data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']);
|
||||
}
|
||||
|
||||
$schedule = new BackupSchedule;
|
||||
$schedule->forceFill([
|
||||
'frequency' => $data['frequency'] ?? 'daily',
|
||||
'time_of_day' => $data['time_of_day'] ?? '00:00:00',
|
||||
'timezone' => $data['timezone'] ?? 'UTC',
|
||||
'days_of_week' => (array) ($data['days_of_week'] ?? []),
|
||||
]);
|
||||
|
||||
$nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule);
|
||||
|
||||
$data['next_run_at'] = $nextRun?->toDateTimeString();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function normalizeTimeOfDay(string $time): string
|
||||
{
|
||||
if (preg_match('/^\d{2}:\d{2}$/', $time)) {
|
||||
return $time.':00';
|
||||
}
|
||||
|
||||
return $time;
|
||||
}
|
||||
|
||||
protected static function timezoneOptions(): array
|
||||
{
|
||||
$zones = DateTimeZone::listIdentifiers();
|
||||
|
||||
sort($zones);
|
||||
|
||||
return array_combine($zones, $zones);
|
||||
}
|
||||
|
||||
protected static function policyTypeOptions(): array
|
||||
{
|
||||
return static::policyTypeLabelMap();
|
||||
}
|
||||
|
||||
protected static function policyTypeLabels(array $types): array
|
||||
{
|
||||
$map = static::policyTypeLabelMap();
|
||||
|
||||
return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types);
|
||||
}
|
||||
|
||||
protected static function policyTypeLabelMap(): array
|
||||
{
|
||||
return collect(config('tenantpilot.supported_policy_types', []))
|
||||
->mapWithKeys(fn (array $policy) => [
|
||||
$policy['type'] => $policy['label'] ?? Str::headline($policy['type']),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
protected static function dayOfWeekOptions(): array
|
||||
{
|
||||
return [
|
||||
1 => 'Monday',
|
||||
2 => 'Tuesday',
|
||||
3 => 'Wednesday',
|
||||
4 => 'Thursday',
|
||||
5 => 'Friday',
|
||||
6 => 'Saturday',
|
||||
7 => 'Sunday',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBackupSchedule extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||
$data = BackupScheduleResource::assignTenant($data);
|
||||
|
||||
return BackupScheduleResource::hydrateNextRun($data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBackupSchedule extends EditRecord
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data = BackupScheduleResource::ensurePolicyTypes($data);
|
||||
|
||||
return BackupScheduleResource::hydrateNextRun($data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSchedules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupScheduleRunsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'runs';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet'))
|
||||
->defaultSort('scheduled_for', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('scheduled_for')
|
||||
->label('Scheduled for')
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'success',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'warning',
|
||||
BackupScheduleRun::STATUS_RUNNING => 'primary',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'gray',
|
||||
BackupScheduleRun::STATUS_FAILED,
|
||||
BackupScheduleRun::STATUS_CANCELED => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('duration')
|
||||
->label('Duration')
|
||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||
if (! $record->started_at || ! $record->finished_at) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
|
||||
|
||||
if ($seconds < 60) {
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$rem = $seconds % 60;
|
||||
|
||||
return sprintf('%dm %ds', $minutes, $rem);
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('counts')
|
||||
->label('Counts')
|
||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
$total = (int) ($summary['policies_total'] ?? 0);
|
||||
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$errors = (int) ($summary['errors_count'] ?? 0);
|
||||
|
||||
if ($total === 0 && $backedUp === 0 && $errors === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('error_code')
|
||||
->label('Error')
|
||||
->badge()
|
||||
->default('—'),
|
||||
Tables\Columns\TextColumn::make('error_message')
|
||||
->label('Message')
|
||||
->default('—')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->default('—')
|
||||
->url(function (BackupScheduleRun $record): ?string {
|
||||
if (! $record->backup_set_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->modalHeading('View backup schedule run')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (BackupScheduleRun $record): View {
|
||||
return view('filament.modals.backup-schedule-run-view', [
|
||||
'run' => $record,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
100
app/Jobs/ApplyBackupScheduleRetentionJob.php
Normal file
100
app/Jobs/ApplyBackupScheduleRetentionJob.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ApplyBackupScheduleRetentionJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public int $backupScheduleId) {}
|
||||
|
||||
public function handle(AuditLogger $auditLogger): void
|
||||
{
|
||||
$schedule = BackupSchedule::query()
|
||||
->with('tenant')
|
||||
->find($this->backupScheduleId);
|
||||
|
||||
if (! $schedule || ! $schedule->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keepLast = (int) ($schedule->retention_keep_last ?? 30);
|
||||
|
||||
if ($keepLast < 1) {
|
||||
$keepLast = 1;
|
||||
}
|
||||
|
||||
/** @var Collection<int, int> $keepBackupSetIds */
|
||||
$keepBackupSetIds = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $schedule->id)
|
||||
->whereNotNull('backup_set_id')
|
||||
->orderByDesc('scheduled_for')
|
||||
->limit($keepLast)
|
||||
->pluck('backup_set_id')
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
/** @var Collection<int, int> $deleteBackupSetIds */
|
||||
$deleteBackupSetIds = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $schedule->id)
|
||||
->whereNotNull('backup_set_id')
|
||||
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
|
||||
->pluck('backup_set_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($deleteBackupSetIds->isEmpty()) {
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
action: 'backup_schedule.retention_applied',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $schedule->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'keep_last' => $keepLast,
|
||||
'deleted_backup_sets' => 0,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
BackupSet::query()
|
||||
->where('tenant_id', $schedule->tenant_id)
|
||||
->whereIn('id', $deleteBackupSetIds->all())
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
|
||||
foreach ($sets as $set) {
|
||||
$set->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
action: 'backup_schedule.retention_applied',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $schedule->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'keep_last' => $keepLast,
|
||||
'deleted_backup_sets' => $deletedCount,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
275
app/Jobs/RunBackupScheduleJob.php
Normal file
275
app/Jobs/RunBackupScheduleJob.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RunBackupScheduleJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public int $backupScheduleRunId) {}
|
||||
|
||||
public function handle(
|
||||
PolicySyncService $policySyncService,
|
||||
BackupService $backupService,
|
||||
PolicyTypeResolver $policyTypeResolver,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
AuditLogger $auditLogger,
|
||||
RunErrorMapper $errorMapper,
|
||||
): void {
|
||||
$run = BackupScheduleRun::query()
|
||||
->with(['schedule', 'tenant'])
|
||||
->find($this->backupScheduleRunId);
|
||||
|
||||
if (! $run) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schedule = $run->schedule;
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
$run->update([
|
||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
||||
'error_message' => 'Schedule not found.',
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $run->tenant;
|
||||
|
||||
if (! $tenant) {
|
||||
$run->update([
|
||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
||||
'error_message' => 'Tenant not found.',
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
errorCode: 'CONCURRENT_RUN',
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
summary: ['reason' => 'concurrent_run'],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$run->forceFill([
|
||||
'started_at' => $run->started_at ?? $nowUtc,
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
])->save();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success'
|
||||
);
|
||||
|
||||
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
|
||||
$validTypes = $runtime['valid'];
|
||||
$unknownTypes = $runtime['unknown'];
|
||||
|
||||
if (empty($validTypes)) {
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
errorCode: 'UNKNOWN_POLICY_TYPE',
|
||||
errorMessage: 'All configured policy types are unknown.',
|
||||
summary: [
|
||||
'unknown_policy_types' => $unknownTypes,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$supported = array_values(array_filter(
|
||||
config('tenantpilot.supported_policy_types', []),
|
||||
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
|
||||
));
|
||||
|
||||
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
|
||||
|
||||
$policyIds = $syncReport['synced'] ?? [];
|
||||
$syncFailures = $syncReport['failures'] ?? [];
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: $policyIds,
|
||||
actorEmail: null,
|
||||
actorName: null,
|
||||
name: 'Scheduled backup: '.$schedule->name,
|
||||
includeAssignments: false,
|
||||
includeScopeTags: false,
|
||||
includeFoundations: (bool) ($schedule->include_foundations ?? false),
|
||||
);
|
||||
|
||||
$status = match ($backupSet->status) {
|
||||
'completed' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'partial' => BackupScheduleRun::STATUS_PARTIAL,
|
||||
'failed' => BackupScheduleRun::STATUS_FAILED,
|
||||
default => BackupScheduleRun::STATUS_SUCCESS,
|
||||
};
|
||||
|
||||
$errorCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
$summary = [
|
||||
'policies_total' => count($policyIds),
|
||||
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
|
||||
'sync_failures' => $syncFailures,
|
||||
];
|
||||
|
||||
if (! empty($unknownTypes)) {
|
||||
$status = BackupScheduleRun::STATUS_PARTIAL;
|
||||
$errorCode = 'UNKNOWN_POLICY_TYPE';
|
||||
$errorMessage = 'Some configured policy types are unknown and were skipped.';
|
||||
$summary['unknown_policy_types'] = $unknownTypes;
|
||||
}
|
||||
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: $status,
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $errorMessage,
|
||||
summary: $summary,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
backupSetId: (string) $backupSet->id,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_finished',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'status' => $status,
|
||||
'error_code' => $errorCode,
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
|
||||
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
||||
|
||||
if ($mapped['shouldRetry']) {
|
||||
$this->release($mapped['delay']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_FAILED,
|
||||
errorCode: $mapped['error_code'],
|
||||
errorMessage: $mapped['error_message'],
|
||||
summary: [
|
||||
'exception' => get_class($throwable),
|
||||
'attempt' => $attempt,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_failed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'error_code' => $mapped['error_code'],
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'failed'
|
||||
);
|
||||
} finally {
|
||||
optional($lock)->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function finishRun(
|
||||
BackupScheduleRun $run,
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
?string $errorCode,
|
||||
?string $errorMessage,
|
||||
array $summary,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
?string $backupSetId = null,
|
||||
): void {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$run->forceFill([
|
||||
'status' => $status,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $errorMessage,
|
||||
'summary' => Arr::wrap($summary),
|
||||
'finished_at' => $nowUtc,
|
||||
'backup_set_id' => $backupSetId,
|
||||
])->save();
|
||||
|
||||
$schedule->forceFill([
|
||||
'last_run_at' => $nowUtc,
|
||||
'last_run_status' => $status,
|
||||
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||
])->saveQuietly();
|
||||
|
||||
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Models/BackupSchedule.php
Normal file
34
app/Models/BackupSchedule.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BackupSchedule extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'is_enabled' => 'boolean',
|
||||
'include_foundations' => 'boolean',
|
||||
'days_of_week' => 'array',
|
||||
'policy_types' => 'array',
|
||||
'last_run_at' => 'datetime',
|
||||
'next_run_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function runs(): HasMany
|
||||
{
|
||||
return $this->hasMany(BackupScheduleRun::class);
|
||||
}
|
||||
}
|
||||
48
app/Models/BackupScheduleRun.php
Normal file
48
app/Models/BackupScheduleRun.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BackupScheduleRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_for' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'summary' => 'array',
|
||||
];
|
||||
|
||||
public function schedule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function backupSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupSet::class);
|
||||
}
|
||||
}
|
||||
@ -175,6 +175,16 @@ public function backupSets(): HasMany
|
||||
return $this->hasMany(BackupSet::class);
|
||||
}
|
||||
|
||||
public function backupSchedules(): HasMany
|
||||
{
|
||||
return $this->hasMany(BackupSchedule::class);
|
||||
}
|
||||
|
||||
public function backupScheduleRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(BackupScheduleRun::class);
|
||||
}
|
||||
|
||||
public function policyVersions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PolicyVersion::class);
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BackupScheduleRunDispatchedNotification extends Notification
|
||||
{
|
||||
/**
|
||||
* @param array{
|
||||
* tenant_id:int,
|
||||
* trigger:string,
|
||||
* scheduled_for:string,
|
||||
* backup_schedule_id?:int,
|
||||
* backup_schedule_run_id?:int,
|
||||
* schedule_ids?:array<int, int>,
|
||||
* backup_schedule_run_ids?:array<int, int>
|
||||
* } $metadata
|
||||
*/
|
||||
public function __construct(public array $metadata) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$trigger = (string) ($this->metadata['trigger'] ?? 'run_now');
|
||||
|
||||
$title = match ($trigger) {
|
||||
'retry' => 'Retry dispatched',
|
||||
'bulk_retry' => 'Retries dispatched',
|
||||
'bulk_run_now' => 'Runs dispatched',
|
||||
default => 'Run dispatched',
|
||||
};
|
||||
|
||||
$body = match ($trigger) {
|
||||
'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.',
|
||||
default => 'A backup run has been queued.',
|
||||
};
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Policies/BackupSchedulePolicy.php
Normal file
46
app/Policies/BackupSchedulePolicy.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class BackupSchedulePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
protected function resolveRole(User $user): ?TenantRole
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $user->tenantRole($tenant);
|
||||
}
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $this->resolveRole($user) !== null;
|
||||
}
|
||||
|
||||
public function view(User $user, BackupSchedule $schedule): bool
|
||||
{
|
||||
return $this->resolveRole($user) !== null;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public function update(User $user, BackupSchedule $schedule): bool
|
||||
{
|
||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
|
||||
public function delete(User $user, BackupSchedule $schedule): bool
|
||||
{
|
||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
@ -23,6 +25,7 @@
|
||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
||||
use Filament\Events\TenantSet;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@ -102,5 +105,7 @@ public function boot(): void
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Rules/SupportedPolicyTypesRule.php
Normal file
22
app/Rules/SupportedPolicyTypesRule.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class SupportedPolicyTypesRule implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$types = array_values((array) $value);
|
||||
|
||||
try {
|
||||
app(PolicyTypeResolver::class)->ensureSupported($types);
|
||||
} catch (InvalidPolicyTypeException $exception) {
|
||||
$fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes)));
|
||||
}
|
||||
}
|
||||
}
|
||||
133
app/Services/BackupScheduling/BackupScheduleDispatcher.php
Normal file
133
app/Services/BackupScheduling/BackupScheduleDispatcher.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BackupScheduling;
|
||||
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
class BackupScheduleDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScheduleTimeService $scheduleTimeService,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Dispatch due schedules.
|
||||
*
|
||||
* No catch-up policy: we only dispatch if the current minute-slot is due.
|
||||
*
|
||||
* @return array{created_runs:int, skipped_runs:int, scanned_schedules:int}
|
||||
*/
|
||||
public function dispatchDue(?array $tenantIdentifiers = null): array
|
||||
{
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$schedulesQuery = BackupSchedule::query()
|
||||
->where('is_enabled', true)
|
||||
->whereHas('tenant', fn ($query) => $query->where('status', 'active'))
|
||||
->with('tenant');
|
||||
|
||||
if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) {
|
||||
$schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers));
|
||||
}
|
||||
|
||||
$createdRuns = 0;
|
||||
$skippedRuns = 0;
|
||||
$scannedSchedules = 0;
|
||||
|
||||
foreach ($schedulesQuery->cursor() as $schedule) {
|
||||
$scannedSchedules++;
|
||||
|
||||
$slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute());
|
||||
|
||||
if ($slot === null) {
|
||||
$schedule->forceFill(['next_run_at' => null])->saveQuietly();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($slot->greaterThan($nowUtc)) {
|
||||
if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) {
|
||||
$schedule->forceFill(['next_run_at' => $slot])->saveQuietly();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$run = null;
|
||||
|
||||
try {
|
||||
$run = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $schedule->tenant_id,
|
||||
'scheduled_for' => $slot->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
'summary' => null,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
// Idempotency: unique (backup_schedule_id, scheduled_for)
|
||||
$skippedRuns++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$createdRuns++;
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
action: 'backup_schedule.run_dispatched',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $slot->toDateTimeString(),
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success'
|
||||
);
|
||||
|
||||
$schedule->forceFill([
|
||||
'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||
])->saveQuietly();
|
||||
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id));
|
||||
}
|
||||
|
||||
return [
|
||||
'created_runs' => $createdRuns,
|
||||
'skipped_runs' => $skippedRuns,
|
||||
'scanned_schedules' => $scannedSchedules,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
* @return array<int>
|
||||
*/
|
||||
private function resolveTenantIds(array $tenantIdentifiers): array
|
||||
{
|
||||
$tenantIds = [];
|
||||
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
$tenant = Tenant::query()
|
||||
->where('status', 'active')
|
||||
->forTenant($identifier)
|
||||
->first();
|
||||
|
||||
if ($tenant) {
|
||||
$tenantIds[] = $tenant->id;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tenantIds));
|
||||
}
|
||||
}
|
||||
55
app/Services/BackupScheduling/PolicyTypeResolver.php
Normal file
55
app/Services/BackupScheduling/PolicyTypeResolver.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BackupScheduling;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PolicyTypeResolver
|
||||
{
|
||||
public function supportedPolicyTypes(): array
|
||||
{
|
||||
return Arr::pluck(config('tenantpilot.supported_policy_types', []), 'type');
|
||||
}
|
||||
|
||||
public function ensureSupported(array $types): void
|
||||
{
|
||||
$unknown = $this->findUnknown($types);
|
||||
|
||||
if (! empty($unknown)) {
|
||||
throw new InvalidPolicyTypeException($unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public function filterRuntime(array $types): array
|
||||
{
|
||||
$valid = $this->filter($types);
|
||||
|
||||
return array_values($valid);
|
||||
}
|
||||
|
||||
public function resolveRuntime(array $types): array
|
||||
{
|
||||
$valid = $this->filter($types);
|
||||
$unknown = $this->findUnknown($types);
|
||||
|
||||
return [
|
||||
'valid' => array_values($valid),
|
||||
'unknown' => array_values($unknown),
|
||||
];
|
||||
}
|
||||
|
||||
protected function filter(array $types): array
|
||||
{
|
||||
$supported = $this->supportedPolicyTypes();
|
||||
|
||||
return array_values(array_intersect($types, $supported));
|
||||
}
|
||||
|
||||
protected function findUnknown(array $types): array
|
||||
{
|
||||
$supported = $this->supportedPolicyTypes();
|
||||
|
||||
return array_values(array_diff($types, $supported));
|
||||
}
|
||||
}
|
||||
86
app/Services/BackupScheduling/RunErrorMapper.php
Normal file
86
app/Services/BackupScheduling/RunErrorMapper.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BackupScheduling;
|
||||
|
||||
use App\Services\Graph\GraphException;
|
||||
use Throwable;
|
||||
|
||||
class RunErrorMapper
|
||||
{
|
||||
public const ERROR_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
|
||||
|
||||
public const ERROR_PERMISSION_MISSING = 'PERMISSION_MISSING';
|
||||
|
||||
public const ERROR_GRAPH_THROTTLE = 'GRAPH_THROTTLE';
|
||||
|
||||
public const ERROR_GRAPH_UNAVAILABLE = 'GRAPH_UNAVAILABLE';
|
||||
|
||||
public const ERROR_UNKNOWN = 'UNKNOWN';
|
||||
|
||||
/**
|
||||
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
|
||||
*/
|
||||
public function map(Throwable $throwable, int $attempt, int $maxAttempts = 3): array
|
||||
{
|
||||
$attempt = max(1, $attempt);
|
||||
|
||||
if ($throwable instanceof GraphException) {
|
||||
$status = $throwable->status;
|
||||
|
||||
if ($status === 401) {
|
||||
return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage());
|
||||
}
|
||||
|
||||
if ($status === 403) {
|
||||
return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage());
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts);
|
||||
}
|
||||
|
||||
if ($status === 503) {
|
||||
return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts);
|
||||
}
|
||||
|
||||
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
|
||||
}
|
||||
|
||||
return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
|
||||
*/
|
||||
private function retry(string $code, string $message, int $attempt, int $maxAttempts): array
|
||||
{
|
||||
if ($attempt >= $maxAttempts) {
|
||||
return $this->final($code, $message);
|
||||
}
|
||||
|
||||
$delays = [60, 300, 900];
|
||||
$delay = $delays[min($attempt - 1, count($delays) - 1)];
|
||||
|
||||
return [
|
||||
'shouldRetry' => true,
|
||||
'delay' => $delay,
|
||||
'error_code' => $code,
|
||||
'error_message' => $message,
|
||||
'final_status' => 'failed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string}
|
||||
*/
|
||||
private function final(string $code, string $message): array
|
||||
{
|
||||
return [
|
||||
'shouldRetry' => false,
|
||||
'delay' => 0,
|
||||
'error_code' => $code,
|
||||
'error_message' => $message,
|
||||
'final_status' => 'failed',
|
||||
];
|
||||
}
|
||||
}
|
||||
88
app/Services/BackupScheduling/ScheduleTimeService.php
Normal file
88
app/Services/BackupScheduling/ScheduleTimeService.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\BackupScheduling;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class ScheduleTimeService
|
||||
{
|
||||
public function nextRunFor(BackupSchedule $schedule, ?CarbonImmutable $after = null): ?CarbonImmutable
|
||||
{
|
||||
$timezone = $schedule->timezone;
|
||||
$cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone);
|
||||
|
||||
if ($schedule->frequency === 'weekly') {
|
||||
return $this->nextWeeklyRun($schedule, $cursor);
|
||||
}
|
||||
|
||||
return $this->nextDailyRun($schedule, $cursor);
|
||||
}
|
||||
|
||||
protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
|
||||
{
|
||||
$time = $schedule->time_of_day;
|
||||
$attempts = 0;
|
||||
|
||||
if ($cursor->format('H:i:s') >= $time) {
|
||||
$cursor = $cursor->addDay();
|
||||
}
|
||||
|
||||
while ($attempts++ < 14) {
|
||||
$candidate = $this->buildLocalSlot($schedule, $cursor);
|
||||
|
||||
if ($candidate) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
$cursor = $cursor->addDay();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable
|
||||
{
|
||||
$allowed = $schedule->days_of_week ?? [];
|
||||
$allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7);
|
||||
$allowed = array_values($allowed);
|
||||
|
||||
if (empty($allowed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attempts = 0;
|
||||
|
||||
while ($attempts++ < 21) {
|
||||
$dayOfWeek = $cursor->dayOfWeekIso;
|
||||
|
||||
if (in_array($dayOfWeek, $allowed, true)) {
|
||||
$candidate = $this->buildLocalSlot($schedule, $cursor);
|
||||
|
||||
$cursorUtc = $cursor->copy()->timezone('UTC');
|
||||
|
||||
if ($candidate && $candidate->greaterThan($cursorUtc)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$cursor = $cursor->addDay()->startOfDay();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable
|
||||
{
|
||||
$timezone = $schedule->timezone;
|
||||
$time = $schedule->time_of_day;
|
||||
$datePart = $date->format('Y-m-d');
|
||||
$candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone);
|
||||
|
||||
if (! $candidate || $candidate->format('H:i:s') !== $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $candidate->startOfMinute()->timezone('UTC');
|
||||
}
|
||||
}
|
||||
@ -18,4 +18,23 @@ public function canSync(): bool
|
||||
self::Readonly => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function canManageBackupSchedules(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner,
|
||||
self::Manager => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function canRunBackupSchedules(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Owner,
|
||||
self::Manager,
|
||||
self::Operator => true,
|
||||
self::Readonly => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_schedules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->boolean('is_enabled')->default(true);
|
||||
$table->string('timezone')->default('UTC');
|
||||
$table->enum('frequency', ['daily', 'weekly']);
|
||||
$table->time('time_of_day');
|
||||
$table->json('days_of_week')->nullable();
|
||||
$table->json('policy_types');
|
||||
$table->boolean('include_foundations')->default(true);
|
||||
$table->integer('retention_keep_last')->default(30);
|
||||
$table->dateTime('last_run_at')->nullable();
|
||||
$table->string('last_run_status')->nullable();
|
||||
$table->dateTime('next_run_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'is_enabled']);
|
||||
$table->index('next_run_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_schedules');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_schedule_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->dateTime('scheduled_for');
|
||||
$table->dateTime('started_at')->nullable();
|
||||
$table->dateTime('finished_at')->nullable();
|
||||
$table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']);
|
||||
$table->json('summary')->nullable();
|
||||
$table->string('error_code')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['backup_schedule_id', 'scheduled_for']);
|
||||
$table->index(['backup_schedule_id', 'scheduled_for']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_schedule_runs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
<x-filament::section>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Scheduled for</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">Status</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->status ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Started at</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->started_at)->toDateTimeString() ?? '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">Finished at</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Error code</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_code ?: '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium">Backup set</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->backup_set_id ?: '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium">Error message</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $run->error_message ?: '—' }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium">Summary</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-xs text-gray-700 dark:bg-gray-900/40 dark:text-gray-200">
|
||||
<pre class="whitespace-pre-wrap">{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
# Requirements Checklist (032)
|
||||
|
||||
- [ ] Tenant-scoped tables use `tenant_id` consistently.
|
||||
- [ ] 1 Run = 1 BackupSet (no rolling reuse in MVP).
|
||||
- [ ] Dispatcher is idempotent (unique schedule_id + scheduled_for).
|
||||
- [ ] Concurrency lock prevents parallel runs per schedule.
|
||||
- [ ] Run stores status + summary + error_code/error_message.
|
||||
- [ ] UI shows schedule list + run history + link to backup set.
|
||||
- [ ] Run now + Retry are permission-gated and write DB notifications.
|
||||
- [ ] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets).
|
||||
- [ ] Retry/backoff policy implemented (no retry for 401/403).
|
||||
- [ ] Retention keeps last N and soft-deletes older backup sets.
|
||||
- [ ] Tests cover due-calculation, idempotency, job success/failure, retention.
|
||||
- [X] Tenant-scoped tables use `tenant_id` consistently. (Data model section in spec.md documents tenant_id on `backup_schedules` and `backup_schedule_runs`.)
|
||||
- [X] 1 Run = 1 BackupSet (no rolling reuse in MVP). (Definitions + Goals in spec.md state the MVP semantics explicitly.)
|
||||
- [X] Dispatcher is idempotent (unique schedule_id + scheduled_for). (Requirements FR-002 + FR-007 + plan's idempotent dispatch constraint specify unique slots.)
|
||||
- [X] Concurrency lock prevents parallel runs per schedule. (FR-008 and plan note per-schedule concurrency lock; tasks T024/Run job mention locking.)
|
||||
- [X] Run stores status + summary + error_code/error_message. (FR-004 and data model show these fields exist in `backup_schedule_runs`.)
|
||||
- [X] UI shows schedule list + run history + link to backup set. (UX-001/UX-002 in spec, tasks T014 / relation managers + UI doc.)
|
||||
- [X] Run now + Retry are permission-gated and write DB notifications. (SEC-002 + tasks T031-T034 describe Filament actions + notifications.)
|
||||
- [X] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). (SEC-003 plus tasks T026/T033/T034 mention audit logging.)
|
||||
- [X] Retry/backoff policy implemented (no retry for 401/403). (NFR-003 and tasks T025 mention retry/backoff rules.)
|
||||
- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.)
|
||||
- [X] Tests cover due-calculation, idempotency, job success/failure, retention. (Tasks T011-T037 include Pest tests for due calculation, idempotency, job outcomes, and retention.)
|
||||
|
||||
@ -29,7 +29,7 @@ ## Constitution Check
|
||||
- Graph Abstraction & Contracts: PASS (sync uses `GraphClientInterface` via `PolicySyncService`; unknown policy types fail-safe; no hardcoded endpoints)
|
||||
- Least Privilege: PASS (authorization via TenantRole matrix; no new scopes required beyond existing backup/sync)
|
||||
- Spec-First Workflow: PASS (spec/plan/tasks/checklist in `specs/032-backup-scheduling-mvp/`)
|
||||
- Quality Gates: PASS (tasks include Pest coverage and Pint)
|
||||
- Quality Gates: PASS (tasks include Pest coverage per constitution and Pint)
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
@ -29,6 +29,10 @@ ## Run the dispatcher manually (MVP)
|
||||
|
||||
- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch`
|
||||
|
||||
Optional: limit dispatching to specific tenants:
|
||||
|
||||
- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch --tenant=<tenant_id_or_external_id>`
|
||||
|
||||
## Run the Laravel scheduler
|
||||
|
||||
Recommended operations model:
|
||||
@ -56,7 +60,12 @@ ## Verify outcomes
|
||||
- In run history: verify status, duration, error_code/message
|
||||
- For successful/partial runs: verify a linked `BackupSet` exists
|
||||
|
||||
Retention
|
||||
|
||||
- After a successful/partial run creates a `BackupSet`, the retention job runs asynchronously and soft-deletes older `BackupSet`s so only the last N (per schedule) remain.
|
||||
|
||||
## Notes
|
||||
|
||||
- Unknown `policy_types` cannot be saved; legacy DB values are handled fail-safe at runtime.
|
||||
- Scheduled runs do not notify a user; interactive actions (Run now / Retry) should persist a DB notification for the acting user.
|
||||
- Run now / Retry actions are available for `operator`+ roles.
|
||||
|
||||
@ -19,8 +19,10 @@ ## Goals
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-01-05
|
||||
- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types.
|
||||
- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status? → A: `skipped` (fail-safe).
|
||||
- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind?
|
||||
→ A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types.
|
||||
- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status?
|
||||
→ A: `skipped` (fail-safe).
|
||||
|
||||
## Non-Goals (MVP)
|
||||
- Kein Kalender-UI als Pflicht (kann später ergänzt werden).
|
||||
@ -50,7 +52,7 @@ ### Functional Requirements
|
||||
- **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes.
|
||||
- **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job.
|
||||
- **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule.
|
||||
- **FR-007**: Retention hält nur die letzten N Runs/BackupSets pro Schedule (soft delete BackupSets).
|
||||
- **FR-007**: Retention hält nur die letzten N BackupSets pro Schedule (soft delete BackupSets).
|
||||
- **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode).
|
||||
|
||||
### UX Requirements (Filament)
|
||||
@ -124,6 +126,5 @@ ## Acceptance Criteria
|
||||
- UI zeigt Last Run + Next Run + Run-History.
|
||||
- Run now startet sofort.
|
||||
- Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code.
|
||||
- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt und der Run wird als `partial` markiert mit `error_code=UNKNOWN_POLICY_TYPE`.
|
||||
- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt: mit valid types → `partial`, ohne valid types → `skipped` (jeweils `error_code=UNKNOWN_POLICY_TYPE`).
|
||||
- Retention hält nur die letzten N BackupSets pro Schedule.
|
||||
|
||||
@ -1,42 +1,113 @@
|
||||
---
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: Backup Scheduling MVP (032)
|
||||
|
||||
**Date**: 2026-01-05
|
||||
**Input**: spec.md, plan.md
|
||||
**Input**: Design documents from `specs/032-backup-scheduling-mvp/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
## Phase 1: Spec & Setup
|
||||
- [ ] T001 Create specs/032-backup-scheduling-mvp (spec/plan/tasks + checklist).
|
||||
**Tests**: Required by constitution quality gate (Pest) even for MVP.
|
||||
|
||||
## Phase 2: Data Model
|
||||
- [ ] T002 Add migrations: backup_schedules + backup_schedule_runs (tenant-scoped, indexes, unique slot).
|
||||
- [ ] T003 Add models + relationships (Tenant->schedules, Schedule->runs, Run->backupSet).
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
## Phase 3: Scheduling + Dispatch
|
||||
- [ ] T004 Add command `tenantpilot:schedules:dispatch`.
|
||||
- [ ] T005 Register scheduler to run every minute.
|
||||
- [ ] T006 Implement due-calculation (timezone, daily/weekly) + next_run_at computation.
|
||||
- [ ] T007 Implement idempotent run creation (unique slot) + cache lock.
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (US1/US2/US3)
|
||||
- Every task description includes at least one explicit file path
|
||||
|
||||
## Phase 4: Jobs
|
||||
- [ ] T008 Implement `RunBackupScheduleJob` (sync -> select policy IDs -> create backup set -> update run + schedule).
|
||||
- [ ] T009 Implement `ApplyBackupScheduleRetentionJob` (keep last N, soft-delete backup sets).
|
||||
- [ ] T010 Add error mapping to `error_code` (TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN).
|
||||
- [ ] T021 Add audit logging for dispatcher/run/retention (tenant-scoped; no secrets).
|
||||
- [ ] T022 Implement retry/backoff strategy for `RunBackupScheduleJob` (no retry on 401/403).
|
||||
## User Stories
|
||||
|
||||
## Phase 5: Filament UI
|
||||
- [ ] T011 Add `BackupScheduleResource` (tenant-scoped): CRUD + enable/disable.
|
||||
- [ ] T012 Add Runs UI (relation manager or resource) with details + link to BackupSet.
|
||||
- [ ] T013 Add actions: Run now + Retry (permission-gated); notifications persisted to DB.
|
||||
- [ ] T023 Wire authorization to TenantRole (readonly/operator/manager/owner) for schedule CRUD and run actions.
|
||||
- **US1 (P1)**: Manage tenant-scoped backup schedules (CRUD, validation).
|
||||
- **US2 (P1)**: Dispatch + execute runs idempotently (queue/scheduler), write runs + audit logs.
|
||||
- **US3 (P2)**: View run history, run-now/retry actions, retention keep-last-N.
|
||||
|
||||
## Phase 6: Tests
|
||||
- [ ] T014 Unit: due-calculation + next_run_at.
|
||||
- [ ] T015 Feature: dispatcher idempotency (unique slot); lock behavior.
|
||||
- [ ] T016 Job-level: successful run creates backup set, updates run/schedule (Graph mocked).
|
||||
- [ ] T017 Job-level: token/permission/throttle errors map to error_code and status.
|
||||
- [ ] T018 Retention: keeps last N and deletes older backup sets.
|
||||
- [ ] T024 Tests: audit logs written (run success + retention delete) and retry policy behavior.
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
- [X] T001 [P] [US2] Review existing sync + backup APIs in app/Services/Intune/PolicySyncService.php and app/Services/Intune/BackupService.php
|
||||
- [X] T002 [P] [US1] Confirm supported policy types config key/shape in config/tenantpilot.php (source of truth for `policy_types`)
|
||||
- [X] T003 [P] [US2] Confirm audit logging primitives in app/Services/Intune/AuditLogger.php and app/Models/AuditLog.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
- [X] T004 [US1] Add migrations for schedules/runs in database/migrations/*_create_backup_schedules_table.php and database/migrations/*_create_backup_schedule_runs_table.php (tenant FKs, jsonb, indexes, unique (backup_schedule_id, scheduled_for))
|
||||
- [X] T005 [P] [US1] Add model app/Models/BackupSchedule.php (casts, relationships)
|
||||
- [X] T006 [P] [US2] Add model app/Models/BackupScheduleRun.php (casts, relationships, status + error fields)
|
||||
- [X] T007 [P] [US1] Add tenant relationships in app/Models/Tenant.php (backupSchedules(), backupScheduleRuns())
|
||||
- [X] T008 [P] [US1] Add TenantRole helpers in app/Support/TenantRole.php for SEC-002 (canManageBackupSchedules(), canRunBackupSchedules())
|
||||
- [X] T009 [US2] Implement next-run + slot calculation in app/Services/BackupScheduling/ScheduleTimeService.php (UTC minute-slot, DST rules, no catch-up)
|
||||
- [X] T010 [US2] Implement policy type validation/filtering in app/Services/BackupScheduling/PolicyTypeResolver.php (validate vs config/tenantpilot.php; runtime filtering for legacy DB)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Manage Schedules (Priority: P1)
|
||||
|
||||
### Tests (Pest)
|
||||
|
||||
- [X] T011 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleCrudTest.php (tenant scoping + manager/owner CRUD)
|
||||
- [X] T012 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleValidationTest.php (weekly days_of_week rules; `policy_types` hard validation)
|
||||
- [X] T013 [P] [US1] Add unit test tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php (next_run_at + DST invalid/ambiguous behavior)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T014 [US1] Implement resource app/Filament/Resources/BackupScheduleResource.php (list columns per UX-001; create/edit form fields)
|
||||
- [X] T015 [US1] Enforce authorization in app/Filament/Resources/BackupScheduleResource.php using app/Support/TenantRole.php (SEC-002)
|
||||
- [X] T016 [US1] Persist next_run_at updates via app/Services/BackupScheduling/ScheduleTimeService.php (on create/update)
|
||||
- [X] T017 [US1] Validate `policy_types` via app/Services/BackupScheduling/PolicyTypeResolver.php (reject unknown keys at save-time)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Dispatch & Execute Runs (Priority: P1)
|
||||
|
||||
### Tests (Pest)
|
||||
|
||||
- [X] T018 [P] [US2] Add feature test tests/Feature/BackupScheduling/DispatchIdempotencyTest.php (same slot dispatch twice → one run)
|
||||
- [X] T019 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (success/partial/skipped outcomes + backup_set linkage)
|
||||
- [X] T020 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunErrorMappingTest.php (retry/backoff vs no-retry mapping)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T021 [P] [US2] Add command app/Console/Commands/TenantpilotDispatchBackupSchedules.php (tenantpilot:schedules:dispatch)
|
||||
- [X] T022 [US2] Register scheduler entry in routes/console.php (every minute)
|
||||
- [X] T023 [US2] Implement dispatcher service app/Services/BackupScheduling/BackupScheduleDispatcher.php (find due schedules, create run, dispatch job)
|
||||
- [X] T024 [US2] Implement job app/Jobs/RunBackupScheduleJob.php (lock per schedule, sync via app/Services/Intune/PolicySyncService.php, create backup set via app/Services/Intune/BackupService.php, update run + schedule fields)
|
||||
- [X] T025 [US2] Implement retry/backoff in app/Jobs/RunBackupScheduleJob.php (429/503 backoff; 401/403 no retry; unknown limited retries)
|
||||
- [X] T026 [US2] Write audit logs (dispatch/run start/run end) using app/Services/Intune/AuditLogger.php from app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php
|
||||
- [X] T027 [US2] Implement unknown policy types runtime behavior in app/Jobs/RunBackupScheduleJob.php using app/Services/BackupScheduling/PolicyTypeResolver.php (≥1 valid → partial; 0 valid → skipped; error_code UNKNOWN_POLICY_TYPE + list in run summary)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Run History, Actions, Retention (Priority: P2)
|
||||
|
||||
### Tests (Pest)
|
||||
|
||||
- [X] T028 [P] [US3] Add feature test tests/Feature/BackupScheduling/RunNowRetryActionsTest.php (operator+ allowed; DB notification persisted)
|
||||
- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older)
|
||||
- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression)
|
||||
- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T030 [US3] Add runs RelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (UX-002 fields + tenant scoping)
|
||||
- [X] T031 [US3] Add Run now / Retry actions in app/Filament/Resources/BackupScheduleResource.php (SEC-002 gating via app/Support/TenantRole.php)
|
||||
- [X] T032 [US3] Persist database notifications for interactive actions in app/Filament/Resources/BackupScheduleResource.php
|
||||
- [X] T033 [US3] Implement retention job app/Jobs/ApplyBackupScheduleRetentionJob.php (soft-delete old backup sets; write audit log)
|
||||
- [X] T034 [US3] Dispatch retention job from app/Jobs/RunBackupScheduleJob.php after completion
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T035 [P] [US2] Validate operational steps in specs/032-backup-scheduling-mvp/quickstart.md (queue + scheduler + manual dispatch)
|
||||
- [X] T036 [P] [US2] Run formatter on touched files using vendor/bin/pint --dirty
|
||||
- [X] T037 [P] [US2] Run targeted tests using vendor/bin/sail php artisan test tests/Feature/BackupScheduling tests/Unit/BackupScheduling
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish
|
||||
|
||||
## Phase 7: Verification
|
||||
- [ ] T019 Run targeted tests (Pest).
|
||||
- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
|
||||
67
tests/Feature/BackupScheduling/ApplyRetentionJobTest.php
Normal file
67
tests/Feature/BackupScheduling/ApplyRetentionJobTest.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
test('retention keeps last N backup sets per schedule', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 2,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$sets = collect(range(1, 5))->map(function (int $i) use ($tenant): BackupSet {
|
||||
return BackupSet::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set '.$i,
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
'completed_at' => now()->subMinutes(10 - $i),
|
||||
]);
|
||||
});
|
||||
|
||||
// Oldest → newest
|
||||
$scheduledFor = now('UTC')->startOfMinute()->subMinutes(10);
|
||||
foreach ($sets as $set) {
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => $scheduledFor,
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0],
|
||||
'backup_set_id' => $set->id,
|
||||
]);
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
}
|
||||
|
||||
ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id);
|
||||
|
||||
$kept = $sets->take(-2);
|
||||
$deleted = $sets->take(3);
|
||||
|
||||
foreach ($kept as $set) {
|
||||
$this->assertDatabaseHas('backup_sets', [
|
||||
'id' => $set->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($deleted as $set) {
|
||||
$this->assertSoftDeleted('backup_sets', ['id' => $set->id]);
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Models\BackupSchedule;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('owner can bulk delete backup schedules', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Delete A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Delete B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('operator cannot bulk delete backup schedules', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Keep A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Keep B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
try {
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB]));
|
||||
} catch (\Throwable) {
|
||||
// Action should be hidden/blocked for operator users.
|
||||
}
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count())
|
||||
->toBe(2);
|
||||
});
|
||||
93
tests/Feature/BackupScheduling/BackupScheduleCrudTest.php
Normal file
93
tests/Feature/BackupScheduling/BackupScheduleCrudTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\CreateBackupSchedule;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('backup schedules listing is tenant scoped', function () {
|
||||
[$user, $tenantA] = createUserWithTenant(role: 'manager');
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
|
||||
|
||||
BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'name' => 'Tenant A schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
'name' => 'Tenant B schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceCompliancePolicy'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA)))
|
||||
->assertOk()
|
||||
->assertSee('Tenant A schedule')
|
||||
->assertSee('Device Configuration')
|
||||
->assertDontSee('Tenant B schedule');
|
||||
});
|
||||
|
||||
test('backup schedules pages return 404 for unauthorized tenant', function () {
|
||||
[$user] = createUserWithTenant(role: 'manager');
|
||||
$unauthorizedTenant = Tenant::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($unauthorizedTenant)))
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('manager can create and edit backup schedules via filament', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(CreateBackupSchedule::class)
|
||||
->fillForm([
|
||||
'name' => 'Daily at 10',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00',
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$schedule = BackupSchedule::query()->where('tenant_id', $tenant->id)->first();
|
||||
expect($schedule)->not->toBeNull();
|
||||
expect($schedule->next_run_at)->not->toBeNull();
|
||||
|
||||
Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()])
|
||||
->fillForm([
|
||||
'name' => 'Daily at 11',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$schedule->refresh();
|
||||
expect($schedule->name)->toBe('Daily at 11');
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
|
||||
test('backup schedule run view modal renders run details', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set 174',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$run = BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => [
|
||||
'policies_total' => 7,
|
||||
'policies_backed_up' => 7,
|
||||
'errors_count' => 0,
|
||||
],
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render();
|
||||
|
||||
expect($html)->toContain('Scheduled for');
|
||||
expect($html)->toContain('Status');
|
||||
expect($html)->toContain('Summary');
|
||||
expect($html)->toContain((string) $backupSet->id);
|
||||
});
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\CreateBackupSchedule;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('weekly schedules require at least one day of week', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(CreateBackupSchedule::class)
|
||||
->fillForm([
|
||||
'name' => 'Weekly schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'weekly',
|
||||
'time_of_day' => '10:00',
|
||||
'days_of_week' => [],
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['days_of_week']);
|
||||
});
|
||||
|
||||
test('unknown policy types are rejected at save time', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(CreateBackupSchedule::class)
|
||||
->fillForm([
|
||||
'name' => 'Invalid policy type schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00',
|
||||
'policy_types' => ['definitelyNotARealPolicyType'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors(['policy_types']);
|
||||
});
|
||||
40
tests/Feature/BackupScheduling/DispatchIdempotencyTest.php
Normal file
40
tests/Feature/BackupScheduling/DispatchIdempotencyTest.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Services\BackupScheduling\BackupScheduleDispatcher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('dispatching the same slot twice creates only one run', function () {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
Bus::fake();
|
||||
|
||||
$dispatcher = app(BackupScheduleDispatcher::class);
|
||||
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
|
||||
expect(BackupScheduleRun::query()->count())->toBe(1);
|
||||
|
||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
||||
});
|
||||
123
tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
Normal file
123
tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php
Normal file
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
it('creates a backup set and marks the run successful', 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,
|
||||
]);
|
||||
|
||||
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))->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),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
|
||||
expect($run->backup_set_id)->toBe($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();
|
||||
|
||||
(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),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
|
||||
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
|
||||
expect($run->backup_set_id)->toBeNull();
|
||||
});
|
||||
42
tests/Feature/BackupScheduling/RunErrorMappingTest.php
Normal file
42
tests/Feature/BackupScheduling/RunErrorMappingTest.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\Graph\GraphException;
|
||||
|
||||
it('marks 401 as token expired without retry', function () {
|
||||
$mapper = app(RunErrorMapper::class);
|
||||
|
||||
$mapped = $mapper->map(new GraphException('auth failed', 401), attempt: 1, maxAttempts: 3);
|
||||
|
||||
expect($mapped['shouldRetry'])->toBeFalse();
|
||||
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_TOKEN_EXPIRED);
|
||||
});
|
||||
|
||||
it('marks 403 as permission missing without retry', function () {
|
||||
$mapper = app(RunErrorMapper::class);
|
||||
|
||||
$mapped = $mapper->map(new GraphException('forbidden', 403), attempt: 1, maxAttempts: 3);
|
||||
|
||||
expect($mapped['shouldRetry'])->toBeFalse();
|
||||
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_PERMISSION_MISSING);
|
||||
});
|
||||
|
||||
it('retries throttling with backoff', function () {
|
||||
$mapper = app(RunErrorMapper::class);
|
||||
|
||||
$mapped = $mapper->map(new GraphException('throttled', 429), attempt: 1, maxAttempts: 3);
|
||||
|
||||
expect($mapped['shouldRetry'])->toBeTrue();
|
||||
expect($mapped['delay'])->toBe(60);
|
||||
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_THROTTLE);
|
||||
});
|
||||
|
||||
it('retries service unavailable with backoff', function () {
|
||||
$mapper = app(RunErrorMapper::class);
|
||||
|
||||
$mapped = $mapper->map(new GraphException('unavailable', 503), attempt: 2, maxAttempts: 3);
|
||||
|
||||
expect($mapped['shouldRetry'])->toBeTrue();
|
||||
expect($mapped['delay'])->toBe(300);
|
||||
expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_UNAVAILABLE);
|
||||
});
|
||||
205
tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
Normal file
205
tests/Feature/BackupScheduling/RunNowRetryActionsTest.php
Normal file
@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('operator can run now and it persists a database notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('runNow', $schedule);
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(1);
|
||||
|
||||
$run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first();
|
||||
expect($run)->not->toBeNull();
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->id,
|
||||
'notifiable_type' => User::class,
|
||||
]);
|
||||
});
|
||||
|
||||
test('operator can retry and it persists a database notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('retry', $schedule);
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
});
|
||||
|
||||
test('readonly cannot dispatch run now or retry', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
try {
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('runNow', $schedule);
|
||||
} catch (\Throwable) {
|
||||
// Action should be hidden/blocked for readonly users.
|
||||
}
|
||||
|
||||
try {
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('retry', $schedule);
|
||||
} catch (\Throwable) {
|
||||
// Action should be hidden/blocked for readonly users.
|
||||
}
|
||||
|
||||
expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('operator can bulk run now and it persists a database notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(2);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
});
|
||||
|
||||
test('operator can bulk retry and it persists a database notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'operator');
|
||||
|
||||
$scheduleA = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly A',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$scheduleB = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly B',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '02:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(2);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
});
|
||||
45
tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php
Normal file
45
tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('skips nonexistent DST local time slots for daily schedules', function () {
|
||||
$schedule = new BackupSchedule;
|
||||
$schedule->forceFill([
|
||||
'frequency' => 'daily',
|
||||
'timezone' => 'Europe/Berlin',
|
||||
'time_of_day' => '02:30:00',
|
||||
'days_of_week' => [],
|
||||
]);
|
||||
|
||||
$service = app(ScheduleTimeService::class);
|
||||
|
||||
// On 2026-03-29 in Europe/Berlin, the clock jumps from 02:00 to 03:00 (02:30 is nonexistent).
|
||||
// Using an "after" cursor later than 02:30 on the previous day forces the candidate day to be 2026-03-29.
|
||||
$after = CarbonImmutable::create(2026, 3, 28, 3, 0, 0, 'Europe/Berlin');
|
||||
|
||||
$next = $service->nextRunFor($schedule, $after);
|
||||
|
||||
expect($next)->not->toBeNull();
|
||||
expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-03-30 00:30:00');
|
||||
});
|
||||
|
||||
it('returns null for weekly schedules without allowed days', function () {
|
||||
$schedule = new BackupSchedule;
|
||||
$schedule->forceFill([
|
||||
'frequency' => 'weekly',
|
||||
'timezone' => 'UTC',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => [],
|
||||
]);
|
||||
|
||||
$service = app(ScheduleTimeService::class);
|
||||
|
||||
$next = $service->nextRunFor($schedule, CarbonImmutable::create(2026, 1, 5, 0, 0, 0, 'UTC'));
|
||||
|
||||
expect($next)->toBeNull();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user