plan: generate research, design artifacts (032)

This commit is contained in:
Ahmed Darrazi 2026-01-05 01:11:59 +01:00
parent 25b1f7bd58
commit 7c5b3256d7
6 changed files with 520 additions and 59 deletions

View File

@ -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

View File

@ -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 }

View 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.

View File

@ -1,67 +1,85 @@
# Plan: Backup Scheduling MVP (032)
**Date**: 2026-01-05
**Input**: spec.md
# Implementation Plan: Backup Scheduling MVP (032)
## 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`).
**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`
## 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).
## Summary
## 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)
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.
## 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
## Technical Context
## 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
**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
## 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.
## Constitution Check
## Ops / Deployment Notes
- Requires queue worker.
- Requires scheduler running.
- Missed runs policy (MVP): no catch-up.
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- 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 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

View File

@ -0,0 +1,62 @@
# 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`
## 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
## 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.

View 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.