diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1a9df86..0fb9ff8 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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 diff --git a/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml new file mode 100644 index 0000000..982f465 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml @@ -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 } diff --git a/specs/032-backup-scheduling-mvp/data-model.md b/specs/032-backup-scheduling-mvp/data-model.md new file mode 100644 index 0000000..6231f73 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/data-model.md @@ -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 in range 1..7 (Mon..Sun) +- `policy_types` (jsonb, required) + - array; 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) +- `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. diff --git a/specs/032-backup-scheduling-mvp/plan.md b/specs/032-backup-scheduling-mvp/plan.md index 468d288..6b13973 100644 --- a/specs/032-backup-scheduling-mvp/plan.md +++ b/specs/032-backup-scheduling-mvp/plan.md @@ -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 diff --git a/specs/032-backup-scheduling-mvp/quickstart.md b/specs/032-backup-scheduling-mvp/quickstart.md new file mode 100644 index 0000000..2856358 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/quickstart.md @@ -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. diff --git a/specs/032-backup-scheduling-mvp/research.md b/specs/032-backup-scheduling-mvp/research.md new file mode 100644 index 0000000..5e958b0 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/research.md @@ -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` 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.