plan: generate research, design artifacts (032)
This commit is contained in:
parent
25b1f7bd58
commit
7c5b3256d7
2
.github/agents/copilot-instructions.md
vendored
2
.github/agents/copilot-instructions.md
vendored
@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines
|
|||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
- 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 (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)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## 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 + Laravel 12, Filament v4, Livewire v3
|
||||||
|
|
||||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
- feat/005-bulk-operations: Added PHP 8.4.15
|
||||||
|
|||||||
@ -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)
|
|
||||||
|
|
||||||
**Date**: 2026-01-05
|
# Implementation Plan: Backup Scheduling MVP (032)
|
||||||
**Input**: spec.md
|
|
||||||
|
|
||||||
## Architecture / Reuse
|
**Branch**: `feat/032-backup-scheduling-mvp` | **Date**: 2026-01-05 | **Spec**: specs/032-backup-scheduling-mvp/spec.md
|
||||||
- Reuse existing services:
|
**Input**: Feature specification from `specs/032-backup-scheduling-mvp/spec.md`
|
||||||
- `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`).
|
|
||||||
|
|
||||||
## Scheduling Mechanism
|
## Summary
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Run 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.
|
||||||
- `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)
|
|
||||||
|
|
||||||
## Retry / Backoff
|
## Technical Context
|
||||||
- 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
|
|
||||||
|
|
||||||
## Retention
|
**Language/Version**: PHP 8.4.15
|
||||||
- `ApplyBackupScheduleRetentionJob(schedule_id)`:
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||||
- identify runs ordered newest→oldest
|
**Storage**: PostgreSQL (Sail locally)
|
||||||
- keep last N runs that created a backup_set_id
|
**Testing**: Pest v4
|
||||||
- for older ones: soft-delete referenced BackupSets (and cascade soft-delete items)
|
**Target Platform**: Containerized (Sail local), Dokploy deploy (staging/prod)
|
||||||
- audit log: number of deleted BackupSets
|
**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
|
## Constitution Check
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Ops / Deployment Notes
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
- Requires queue worker.
|
|
||||||
- Requires scheduler running.
|
- Safety-First Restore: PASS (feature is backup-only; no restore scheduling)
|
||||||
- Missed runs policy (MVP): no catch-up.
|
- 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
|
||||||
|
|||||||
62
specs/032-backup-scheduling-mvp/quickstart.md
Normal file
62
specs/032-backup-scheduling-mvp/quickstart.md
Normal 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.
|
||||||
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.
|
||||||
Loading…
Reference in New Issue
Block a user