feat/032-backup-scheduling-mvp (#34)
What Implements tenant-scoped backup scheduling end-to-end: schedules CRUD, minute-based dispatch, queued execution, run history, manual “Run now/Retry”, retention (keep last N), and auditability. Key changes Filament UI: Backup Schedules resource with tenant scoping + SEC-002 role gating. Scheduler + queue: tenantpilot:schedules:dispatch command wired in scheduler (runs every minute), creates idempotent BackupScheduleRun records and dispatches jobs. Execution: RunBackupScheduleJob syncs policies, creates immutable backup sets, updates run status, writes audit logs, applies retry/backoff mapping, and triggers retention. Run history: Relation manager + “View” modal rendering run details. UX polish: row actions grouped; bulk actions grouped (run now / retry / delete). Bulk dispatch writes DB notifications (shows in notifications panel). Validation: policy type hard-validation on save; unknown policy types handled safely at runtime (skipped/partial). Tests: comprehensive Pest coverage for CRUD/scoping/validation, idempotency, job outcomes, error mapping, retention, view modal, run-now/retry notifications, bulk delete (incl. operator forbidden). Files / Areas Filament: BackupScheduleResource.php and app/Filament/Resources/BackupScheduleResource/* Scheduling/Jobs: app/Console/Commands/TenantpilotDispatchBackupSchedules.php, app/Jobs/RunBackupScheduleJob.php, app/Jobs/ApplyBackupScheduleRetentionJob.php, console.php Models/Migrations: app/Models/BackupSchedule.php, app/Models/BackupScheduleRun.php, database/migrations/backup_schedules, backup_schedule_runs Notifications: BackupScheduleRunDispatchedNotification.php Specs: specs/032-backup-scheduling-mvp/* (tasks/checklist/quickstart updates) How to test (Sail) Run tests: ./vendor/bin/sail artisan test tests/Feature/BackupScheduling Run formatter: ./vendor/bin/sail php ./vendor/bin/pint --dirty Apply migrations: ./vendor/bin/sail artisan migrate Manual dispatch: ./vendor/bin/sail artisan tenantpilot:schedules:dispatch Notes Uses DB notifications for queued UI actions to ensure they appear in the notifications panel even under queue fakes in tests. Checklist gate for 032 is PASS; tasks updated accordingly. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #34
This commit is contained in:
parent
beffbfca4c
commit
4d3fcd28a9
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
|
||||
## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -24,6 +25,7 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -13,6 +13,9 @@
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
@ -22,4 +25,6 @@
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/references
|
||||
/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.)
|
||||
- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.)
|
||||
|
||||
@ -0,0 +1,204 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Backup Scheduling (Spec 032)
|
||||
version: "0.1"
|
||||
description: |
|
||||
Conceptual contract for Backup Scheduling MVP. TenantPilot uses Filament/Livewire;
|
||||
these endpoints describe behavior for review/testing and future API alignment.
|
||||
servers:
|
||||
- url: https://{host}
|
||||
variables:
|
||||
host:
|
||||
default: example.local
|
||||
|
||||
paths:
|
||||
/tenants/{tenantId}/backup-schedules:
|
||||
get:
|
||||
summary: List backup schedules for a tenant
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupSchedule'
|
||||
post:
|
||||
summary: Create a backup schedule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupScheduleCreate'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSchedule'
|
||||
'422':
|
||||
description: Validation error (e.g. unknown policy_types)
|
||||
|
||||
/tenants/{tenantId}/backup-schedules/{scheduleId}:
|
||||
patch:
|
||||
summary: Update a backup schedule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ScheduleId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupScheduleUpdate'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupSchedule'
|
||||
'422':
|
||||
description: Validation error
|
||||
delete:
|
||||
summary: Delete (or disable) a schedule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ScheduleId'
|
||||
responses:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/tenants/{tenantId}/backup-schedules/{scheduleId}/run-now:
|
||||
post:
|
||||
summary: Trigger a run immediately
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ScheduleId'
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted (run created and job dispatched)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupScheduleRun'
|
||||
|
||||
/tenants/{tenantId}/backup-schedules/{scheduleId}/retry:
|
||||
post:
|
||||
summary: Create a new run as retry
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ScheduleId'
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BackupScheduleRun'
|
||||
|
||||
/tenants/{tenantId}/backup-schedules/{scheduleId}/runs:
|
||||
get:
|
||||
summary: List runs for a schedule
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/ScheduleId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BackupScheduleRun'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
ScheduleId:
|
||||
name: scheduleId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
|
||||
schemas:
|
||||
BackupSchedule:
|
||||
type: object
|
||||
required: [id, tenant_id, name, is_enabled, timezone, frequency, time_of_day, policy_types, retention_keep_last]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
tenant_id: { type: integer }
|
||||
name: { type: string }
|
||||
is_enabled: { type: boolean }
|
||||
timezone: { type: string, example: "Europe/Berlin" }
|
||||
frequency: { type: string, enum: [daily, weekly] }
|
||||
time_of_day: { type: string, example: "02:00:00" }
|
||||
days_of_week:
|
||||
type: array
|
||||
nullable: true
|
||||
items: { type: integer, minimum: 1, maximum: 7 }
|
||||
policy_types:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: Must be keys from config('tenantpilot.supported_policy_types').
|
||||
include_foundations: { type: boolean }
|
||||
retention_keep_last: { type: integer, minimum: 1 }
|
||||
last_run_at: { type: string, format: date-time, nullable: true }
|
||||
last_run_status: { type: string, nullable: true }
|
||||
next_run_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
BackupScheduleCreate:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/BackupScheduleUpdate'
|
||||
- type: object
|
||||
required: [name, timezone, frequency, time_of_day, policy_types]
|
||||
|
||||
BackupScheduleUpdate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
is_enabled: { type: boolean }
|
||||
timezone: { type: string }
|
||||
frequency: { type: string, enum: [daily, weekly] }
|
||||
time_of_day: { type: string }
|
||||
days_of_week:
|
||||
type: array
|
||||
nullable: true
|
||||
items: { type: integer, minimum: 1, maximum: 7 }
|
||||
policy_types:
|
||||
type: array
|
||||
items: { type: string }
|
||||
include_foundations: { type: boolean }
|
||||
retention_keep_last: { type: integer, minimum: 1 }
|
||||
|
||||
BackupScheduleRun:
|
||||
type: object
|
||||
required: [id, backup_schedule_id, tenant_id, scheduled_for, status]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
backup_schedule_id: { type: integer }
|
||||
tenant_id: { type: integer }
|
||||
scheduled_for: { type: string, format: date-time }
|
||||
started_at: { type: string, format: date-time, nullable: true }
|
||||
finished_at: { type: string, format: date-time, nullable: true }
|
||||
status: { type: string, enum: [running, success, partial, failed, canceled, skipped] }
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
error_code: { type: string, nullable: true }
|
||||
error_message: { type: string, nullable: true }
|
||||
backup_set_id: { type: integer, nullable: true }
|
||||
98
specs/032-backup-scheduling-mvp/data-model.md
Normal file
98
specs/032-backup-scheduling-mvp/data-model.md
Normal file
@ -0,0 +1,98 @@
|
||||
# Data Model: Backup Scheduling MVP (032)
|
||||
|
||||
**Date**: 2026-01-05
|
||||
|
||||
This document describes the entities, relationships, validation rules, and state transitions derived from the feature spec.
|
||||
|
||||
## Entities
|
||||
|
||||
### 1) BackupSchedule (`backup_schedules`)
|
||||
|
||||
**Purpose**: Defines a tenant-scoped recurring backup plan.
|
||||
|
||||
**Fields**
|
||||
- `id` (bigint, PK)
|
||||
- `tenant_id` (FK → `tenants.id`, required)
|
||||
- `name` (string, required)
|
||||
- `is_enabled` (bool, default true)
|
||||
- `timezone` (string, required; default `UTC`)
|
||||
- `frequency` (enum: `daily|weekly`, required)
|
||||
- `time_of_day` (time, required)
|
||||
- `days_of_week` (json, nullable; required when `frequency=weekly`)
|
||||
- array<int> in range 1..7 (Mon..Sun)
|
||||
- `policy_types` (jsonb, required)
|
||||
- array<string>; keys MUST exist in `config('tenantpilot.supported_policy_types')`
|
||||
- `include_foundations` (bool, default true)
|
||||
- `retention_keep_last` (int, default 30)
|
||||
- `last_run_at` (datetime, nullable)
|
||||
- `last_run_status` (string, nullable)
|
||||
- `next_run_at` (datetime, nullable)
|
||||
- timestamps
|
||||
|
||||
**Indexes**
|
||||
- `(tenant_id, is_enabled)`
|
||||
- optional `(next_run_at)`
|
||||
|
||||
**Validation Rules (MVP)**
|
||||
- `tenant_id`: required, exists
|
||||
- `name`: required, max length (e.g. 255)
|
||||
- `timezone`: required, valid IANA tz
|
||||
- `frequency`: required, in `[daily, weekly]`
|
||||
- `time_of_day`: required
|
||||
- `days_of_week`: required if weekly; values 1..7; unique values
|
||||
- `policy_types`: required, array, min 1; all values in supported types config
|
||||
- `retention_keep_last`: required, int, min 1
|
||||
|
||||
**State**
|
||||
- Enabled/disabled (`is_enabled`)
|
||||
|
||||
---
|
||||
|
||||
### 2) BackupScheduleRun (`backup_schedule_runs`)
|
||||
|
||||
**Purpose**: Represents one execution attempt of a schedule.
|
||||
|
||||
**Fields**
|
||||
- `id` (bigint, PK)
|
||||
- `backup_schedule_id` (FK → `backup_schedules.id`, required)
|
||||
- `tenant_id` (FK → `tenants.id`, required; denormalized)
|
||||
- `scheduled_for` (datetime, required; UTC minute-slot)
|
||||
- `started_at` (datetime, nullable)
|
||||
- `finished_at` (datetime, nullable)
|
||||
- `status` (enum: `running|success|partial|failed|canceled|skipped`, required)
|
||||
- `summary` (jsonb, required)
|
||||
- suggested keys:
|
||||
- `policies_total` (int)
|
||||
- `policies_backed_up` (int)
|
||||
- `errors_count` (int)
|
||||
- `type_breakdown` (object)
|
||||
- `warnings` (array)
|
||||
- `unknown_policy_types` (array<string>)
|
||||
- `error_code` (string, nullable)
|
||||
- `error_message` (text, nullable)
|
||||
- `backup_set_id` (FK → `backup_sets.id`, nullable)
|
||||
- timestamps
|
||||
|
||||
**Indexes**
|
||||
- `(backup_schedule_id, scheduled_for)`
|
||||
- `(tenant_id, created_at)`
|
||||
- unique `(backup_schedule_id, scheduled_for)` (idempotency)
|
||||
|
||||
**State transitions**
|
||||
- `running` → `success|partial|failed|skipped|canceled`
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
- Tenant `hasMany` BackupSchedule
|
||||
- BackupSchedule `belongsTo` Tenant
|
||||
- BackupSchedule `hasMany` BackupScheduleRun
|
||||
- BackupScheduleRun `belongsTo` BackupSchedule
|
||||
- BackupScheduleRun `belongsTo` Tenant
|
||||
- BackupScheduleRun `belongsTo` BackupSet (nullable)
|
||||
|
||||
## Notes
|
||||
|
||||
- `BackupSet` and `BackupItem` already support soft deletes in this repo; retention can soft-delete old backup sets.
|
||||
- Unknown policy types are prevented at save-time, but runs defensively re-check to handle legacy DB data.
|
||||
@ -1,67 +1,85 @@
|
||||
# Plan: Backup Scheduling MVP (032)
|
||||
# Implementation Plan: Backup Scheduling MVP (032)
|
||||
|
||||
**Date**: 2026-01-05
|
||||
**Input**: spec.md
|
||||
**Branch**: `feat/032-backup-scheduling-mvp` | **Date**: 2026-01-05 | **Spec**: specs/032-backup-scheduling-mvp/spec.md
|
||||
**Input**: Feature specification from `specs/032-backup-scheduling-mvp/spec.md`
|
||||
|
||||
## Architecture / Reuse
|
||||
- Reuse existing services:
|
||||
- `PolicySyncService::syncPoliciesWithReport()` for selected policy types
|
||||
- `BackupService::createBackupSet()` to create immutable snapshots + items (include_foundations supported)
|
||||
- Store selection as `policy_types` (config keys), not free-form categories.
|
||||
- Use tenant scoping (`tenant_id`) consistent with existing tables (`backup_sets`, `backup_items`).
|
||||
## Summary
|
||||
|
||||
## Scheduling Mechanism
|
||||
- Add Artisan command: `tenantpilot:schedules:dispatch`.
|
||||
- Scheduler integration (Laravel 12): schedule the command every minute via `routes/console.php` + ops configuration (Dokploy cron `schedule:run` or long-running `schedule:work`).
|
||||
- Dispatcher algorithm:
|
||||
1) load enabled schedules
|
||||
2) compute whether due for the current minute in schedule timezone
|
||||
3) create run with `scheduled_for` slot (minute precision) using DB unique constraint
|
||||
4) dispatch `RunBackupScheduleJob(schedule_id, run_id)`
|
||||
- Concurrency:
|
||||
- Cache lock per schedule (`lock:backup_schedule:{id}`) plus DB unique slot constraint for idempotency.
|
||||
- If lock is held: mark run as `skipped` with a clear error_code (no parallel execution).
|
||||
Implement tenant-scoped backup schedules that dispatch idempotent runs every minute via Laravel scheduler and queue workers. Each run syncs selected policy types from Graph into the local DB (via existing `PolicySyncService`) and creates an immutable `BackupSet` snapshot (via existing `BackupService`), with strict audit logging, fail-safe handling for unknown policy types, retention (keep last N), and Filament UI for managing schedules and viewing run history.
|
||||
|
||||
## Run Execution
|
||||
- `RunBackupScheduleJob`:
|
||||
1) load schedule + tenant
|
||||
2) preflight: tenant active; Graph/auth errors mapped to error_code
|
||||
3) sync policies for selected types (collect report)
|
||||
4) select policy IDs from local DB for those types (exclude ignored)
|
||||
5) create backup set:
|
||||
- name: `{schedule_name} - {Y-m-d H:i}`
|
||||
- includeFoundations: schedule flag
|
||||
6) set run status:
|
||||
- success if backup_set.status == completed
|
||||
- partial if backup_set.status == partial OR sync had failures but backup succeeded
|
||||
- failed if nothing backed up / hard error
|
||||
7) update schedule last_run_* and compute/persist next_run_at
|
||||
8) dispatch retention job
|
||||
9) audit logs:
|
||||
- log run start + completion (status, counts, error_code; no secrets)
|
||||
## Technical Context
|
||||
|
||||
## Retry / Backoff
|
||||
- Configure job retry behavior based on error classification:
|
||||
- Throttling/transient (e.g. 429/503): backoff + retry
|
||||
- Auth/permission (401/403): no retry
|
||||
- Unknown: limited retries
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||
**Storage**: PostgreSQL (Sail locally)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Containerized (Sail local), Dokploy deploy (staging/prod)
|
||||
**Project Type**: Web application (Laravel monolith + Filament admin)
|
||||
**Performance Goals**: Scheduler runs every minute; per-run work is queued; avoid long locks
|
||||
**Constraints**: Idempotent dispatch (unique slot), per-schedule concurrency lock, no secrets/tokens in logs, “no catch-up” policy
|
||||
**Scale/Scope**: Multi-tenant MSP use; schedules per tenant; runs stored for audit/history
|
||||
|
||||
## Retention
|
||||
- `ApplyBackupScheduleRetentionJob(schedule_id)`:
|
||||
- identify runs ordered newest→oldest
|
||||
- keep last N runs that created a backup_set_id
|
||||
- for older ones: soft-delete referenced BackupSets (and cascade soft-delete items)
|
||||
- audit log: number of deleted BackupSets
|
||||
## Constitution Check
|
||||
|
||||
## Filament UX
|
||||
- Tenant-scoped resources:
|
||||
- `BackupScheduleResource`
|
||||
- Runs UI via RelationManager under schedule (or a dedicated resource if needed)
|
||||
- Actions: enable/disable, run now, retry
|
||||
- Notifications: persist via `->sendToDatabase($user)` for the DB info panel.
|
||||
- MVP notification scope: only interactive actions notify the acting user; scheduled runs rely on Run history.
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
## Ops / Deployment Notes
|
||||
- Requires queue worker.
|
||||
- Requires scheduler running.
|
||||
- Missed runs policy (MVP): no catch-up.
|
||||
- Safety-First Restore: PASS (feature is backup-only; no restore scheduling)
|
||||
- Auditability & Tenant Isolation: PASS (tenant_id everywhere; audit log entries for dispatch/run/retention)
|
||||
- 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 per constitution and Pint)
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/032-backup-scheduling-mvp/
|
||||
├── plan.md # This file (/speckit.plan output)
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (already present)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Console/Commands/
|
||||
├── Filament/Resources/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
└── Services/
|
||||
|
||||
config/
|
||||
database/migrations/
|
||||
routes/console.php
|
||||
tests/
|
||||
```
|
||||
|
||||
Expected additions for this feature (at implementation time):
|
||||
|
||||
```text
|
||||
app/Console/Commands/TenantpilotDispatchBackupSchedules.php
|
||||
app/Jobs/RunBackupScheduleJob.php
|
||||
app/Jobs/ApplyBackupScheduleRetentionJob.php
|
||||
app/Models/BackupSchedule.php
|
||||
app/Models/BackupScheduleRun.php
|
||||
app/Filament/Resources/BackupScheduleResource.php
|
||||
database/migrations/*_create_backup_schedules_table.php
|
||||
database/migrations/*_create_backup_schedule_runs_table.php
|
||||
tests/Feature/BackupScheduling/*
|
||||
tests/Unit/BackupScheduling/*
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith (Filament admin + queued jobs). No new top-level app folders.
|
||||
|
||||
## Phase Outputs
|
||||
|
||||
- Phase 0 (Outline & Research): `research.md`
|
||||
- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md`
|
||||
- Phase 2 (Tasks): `tasks.md` already exists; will be refined later via `/speckit.tasks` if needed
|
||||
- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md`
|
||||
|
||||
71
specs/032-backup-scheduling-mvp/quickstart.md
Normal file
71
specs/032-backup-scheduling-mvp/quickstart.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Quickstart: Backup Scheduling MVP (032)
|
||||
|
||||
This is a developer/operator quickstart for running the scheduling MVP locally with Sail.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Laravel Sail running
|
||||
- Database migrated
|
||||
- Queue worker running
|
||||
- Scheduler running (or run the dispatch command manually)
|
||||
|
||||
## Local setup (Sail)
|
||||
|
||||
1) Start Sail
|
||||
|
||||
- `./vendor/bin/sail up -d`
|
||||
|
||||
2) Run migrations
|
||||
|
||||
- `./vendor/bin/sail php artisan migrate`
|
||||
|
||||
3) Start a queue worker
|
||||
|
||||
- `./vendor/bin/sail php artisan queue:work`
|
||||
|
||||
## Run the dispatcher manually (MVP)
|
||||
|
||||
Once a schedule exists, you can dispatch due runs:
|
||||
|
||||
- `./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:
|
||||
|
||||
- Dev/local: run `schedule:work` in a separate terminal
|
||||
- `./vendor/bin/sail php artisan schedule:work`
|
||||
|
||||
- Production/staging (Dokploy): cron every minute
|
||||
- `* * * * * php artisan schedule:run`
|
||||
|
||||
## Create a schedule (Filament)
|
||||
|
||||
- Log into Filament admin
|
||||
- Switch into a tenant context
|
||||
- Create a Backup Schedule:
|
||||
- frequency: daily/weekly
|
||||
- time + timezone
|
||||
- policy_types: pick from supported types
|
||||
- retention_keep_last
|
||||
- include_foundations
|
||||
|
||||
## Verify outcomes
|
||||
|
||||
- In the schedule list: check `Last Run` and `Next Run`
|
||||
- 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.
|
||||
77
specs/032-backup-scheduling-mvp/research.md
Normal file
77
specs/032-backup-scheduling-mvp/research.md
Normal file
@ -0,0 +1,77 @@
|
||||
# Research: Backup Scheduling MVP (032)
|
||||
|
||||
**Date**: 2026-01-05
|
||||
|
||||
This document resolves technical decisions and clarifies implementation approach for Feature 032.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Reuse existing sync + backup services
|
||||
- **Decision**: Use `App\Services\Intune\PolicySyncService::syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array` and `App\Services\Intune\BackupService::createBackupSet(...)`.
|
||||
- **Rationale**: These are already tenant-aware, use `GraphClientInterface` behind the scenes (via `PolicySyncService`), and `BackupService` already writes a `backup.created` audit log entry.
|
||||
- **Alternatives considered**:
|
||||
- Implement new Graph calls directly in the scheduler job → rejected (violates Graph abstraction gate; duplicates logic).
|
||||
|
||||
### 2) Policy type source of truth + validation
|
||||
- **Decision**:
|
||||
- Persist `backup_schedules.policy_types` as `array<string>` of **type keys** present in `config('tenantpilot.supported_policy_types')`.
|
||||
- **Hard validation at save-time**: unknown keys are rejected.
|
||||
- **Runtime defensive check** (legacy/DB): unknown keys are skipped.
|
||||
- If ≥1 valid type remains → run becomes `partial` and `error_code=UNKNOWN_POLICY_TYPE`.
|
||||
- If 0 valid types remain → run becomes `skipped` and `error_code=UNKNOWN_POLICY_TYPE` (no `BackupSet` created).
|
||||
- **Rationale**: Prevent silent misconfiguration and enforce fail-safe behavior at entry points, while still handling legacy data safely.
|
||||
- **Alternatives considered**:
|
||||
- Save unknown keys and ignore silently → rejected (silent misconfiguration).
|
||||
- Fail the run for any unknown type → rejected (too brittle for legacy).
|
||||
|
||||
### 3) Graph calls and contracts
|
||||
- **Decision**: Do not hardcode Graph endpoints. All Graph access happens via `GraphClientInterface` (through `PolicySyncService` and `BackupService`).
|
||||
- **Rationale**: Matches constitution requirements and existing code paths.
|
||||
- **Alternatives considered**:
|
||||
- Calling `deviceManagement/{type}` directly → rejected (explicitly forbidden by constitution; also unsafe for unknown types).
|
||||
|
||||
### 4) Scheduling mechanism
|
||||
- **Decision**: Add an Artisan command `tenantpilot:schedules:dispatch` and register it with Laravel scheduler to run every minute.
|
||||
- **Rationale**: Fits Laravel 12 structure (no Kernel), supports Dokploy operation models (`schedule:run` cron or `schedule:work`).
|
||||
- **Alternatives considered**:
|
||||
- Long-running daemon polling DB directly → rejected (less idiomatic; harder ops).
|
||||
|
||||
### 5) Due calculation + time semantics
|
||||
- **Decision**:
|
||||
- `scheduled_for` is minute-slot based and stored in UTC.
|
||||
- Due calculation uses the schedule timezone.
|
||||
- DST (MVP): invalid local time → skip; ambiguous local time → first occurrence.
|
||||
- **Rationale**: Predictable and testable; avoids “surprise catch-up”.
|
||||
- **Alternatives considered**:
|
||||
- Catch-up missed slots → rejected by spec (MVP explicitly “no catch-up”).
|
||||
|
||||
### 6) Idempotency + concurrency
|
||||
- **Decision**:
|
||||
- DB unique constraint: `(backup_schedule_id, scheduled_for)`.
|
||||
- Cache lock per schedule (`lock:backup_schedule:{id}`) to prevent parallel execution.
|
||||
- If lock held, do not run in parallel: mark run `skipped` with a clear error_code.
|
||||
- **Rationale**: Prevents double runs and provides deterministic behavior.
|
||||
- **Alternatives considered**:
|
||||
- Only cache lock (no DB constraint) → rejected (less robust under crashes/restarts).
|
||||
|
||||
### 7) Retry/backoff policy
|
||||
- **Decision**:
|
||||
- Transient/throttling failures (e.g. 429/503) → retries with backoff.
|
||||
- Auth/permission failures (401/403) → no retry.
|
||||
- Unknown failures → limited retries, then fail.
|
||||
- **Rationale**: Avoid noisy retry loops for non-recoverable errors.
|
||||
|
||||
### 8) Audit logging
|
||||
- **Decision**: Use `App\Services\Intune\AuditLogger` for:
|
||||
- dispatch cycle (optional aggregated)
|
||||
- run start + completion
|
||||
- retention applied (count deletions)
|
||||
- **Rationale**: Constitution requires audit log for every operation; existing `BackupService` already writes `backup.created`.
|
||||
|
||||
### 9) Notifications
|
||||
- **Decision**: Only interactive actions (Run now / Retry) notify the acting user (database notifications). Scheduled runs rely on Run history.
|
||||
- **Rationale**: Avoid undefined “who gets notified” without adding new ownership fields.
|
||||
|
||||
## Open Items
|
||||
|
||||
None blocking Phase 1 design.
|
||||
@ -16,6 +16,14 @@ ## Goals
|
||||
- Retention löscht alte Backups nach Policy.
|
||||
- Filament UI: Schedules verwalten, Run-History ansehen, “Run now”, “Retry”.
|
||||
|
||||
## 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).
|
||||
|
||||
## Non-Goals (MVP)
|
||||
- Kein Kalender-UI als Pflicht (kann später ergänzt werden).
|
||||
- Kein Cross-Tenant Bulk Scheduling (MSP-Templates später).
|
||||
@ -37,10 +45,14 @@ ### Functional Requirements
|
||||
- **FR-003**: Run nutzt bestehende Services:
|
||||
- Sync Policies (nur selektierte policy types)
|
||||
- Create BackupSet aus lokalen Policy-IDs (inkl. Foundations optional)
|
||||
- **FR-003a**: `policy_types` sind ausschließlich Keys aus `config('tenantpilot.supported_policy_types')`.
|
||||
- **FR-003b**: UI/Server-side Validation verhindert das Speichern unbekannter `policy_types`.
|
||||
- **FR-003c**: Laufzeit-Validierung (defensiv): Unbekannte `policy_types` werden geskippt; wenn mindestens ein gültiger Type verarbeitet wurde, wird der Run als `partial` markiert und `error_code=UNKNOWN_POLICY_TYPE` gesetzt (inkl. Liste der betroffenen Types in `summary`).
|
||||
- **FR-003d**: Wenn zur Laufzeit nach dem Skip **0 gültige Types** verbleiben, wird **kein BackupSet** erzeugt und der Run als `skipped` markiert (mit `error_code=UNKNOWN_POLICY_TYPE` und Liste der betroffenen Types in `summary`).
|
||||
- **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)
|
||||
@ -114,4 +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: 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 7: Verification
|
||||
- [ ] T019 Run targeted tests (Pest).
|
||||
- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`).
|
||||
## 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
|
||||
Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish
|
||||
|
||||
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