Compare commits

...

85 Commits

Author SHA1 Message Date
alex 54c288bcd0 style: format certification tools 2026-07-31 14:34:34 +02:00
alex f97daa35d0 docs: rewrite v2 operator documentation 2026-07-31 14:34:34 +02:00
alex bd107d6a30 feat(v2): complete v2 reimplementation 2026-07-31 13:34:29 +02:00
alex 396219e776 fix(v2): validate durable execution event replay 2026-07-28 10:52:03 +02:00
alex c558801aac fix(v2): atomically record execution mutations 2026-07-28 10:45:23 +02:00
alex d7d40dc645 fix(v2): atomically publish enqueue event 2026-07-28 10:41:18 +02:00
alex 63c977cea5 feat(v2): persist execution event revisions 2026-07-27 23:00:18 +02:00
alex bb87b04ca8 fix(v2): preserve execution cancellation and retry safety 2026-07-27 22:48:08 +02:00
alex 3b7ef2e874 feat(v2): stream execution revisions over sse 2026-07-27 22:40:16 +02:00
alex 717653b329 test(v2): verify execution event redaction 2026-07-27 22:29:35 +02:00
alex c729f5db80 fix(v2): scope and redact execution events 2026-07-27 22:26:40 +02:00
alex c97dd8cfb2 test(v2): enforce execution scope and progress redaction 2026-07-27 22:23:47 +02:00
alex 9993fafb51 test(v2): fence expired execution leases 2026-07-27 22:11:46 +02:00
alex 8e5dd87ff6 style(v2): normalize execution integration test 2026-07-27 22:07:41 +02:00
alex be56228748 style(v2): normalize worker lifecycle imports 2026-07-27 22:06:29 +02:00
alex 173cf7f75c feat(v2): wire worker role lifecycle 2026-07-27 22:05:45 +02:00
alex 8c07c43ed2 feat(v2): add durable worker lifecycle service 2026-07-27 22:04:02 +02:00
alex 3f1136b99e fix(v2): fence execution leases and redact progress 2026-07-27 22:01:33 +02:00
alex f1651c18ad style(v2): normalize execution control coverage 2026-07-27 21:55:11 +02:00
alex 474efa526f feat(v2): expose execution control and event APIs 2026-07-27 21:54:05 +02:00
alex 057895f138 feat(v2): enqueue and lease durable executions 2026-07-27 21:48:26 +02:00
alex f3858ac43a feat(v2): add guarded execution state transitions 2026-07-27 21:43:54 +02:00
alex 86d1ca168d style(v2): sort source job test imports 2026-07-27 21:37:21 +02:00
alex 61189c27d2 feat(v2): add local sources and repository jobs 2026-07-27 21:36:45 +02:00
alex f773a1b771 test(v2): define local source and job contracts 2026-07-27 21:27:32 +02:00
alex fb4cd4cd3e fix(v2): support nested repository paths 2026-07-27 21:22:54 +02:00
alex 14b4347806 fix(v2): enforce repository safety failures 2026-07-27 21:16:44 +02:00
alex cccd638be2 test(v2): cover repository safety failure paths 2026-07-27 21:13:27 +02:00
alex 908859084a fix(v2): enforce repository protocol safety 2026-07-27 21:10:26 +02:00
alex 175a550c7b fix(auth): persist sessions and lock remote setup 2026-07-27 21:01:04 +02:00
alex 5cbd5b836c test(auth): require durable sessions and safe remote setup 2026-07-27 20:55:42 +02:00
alex a7e68e2eab fix(auth): protect setup and expire sessions 2026-07-27 20:48:03 +02:00
alex e7607a0f3c test(auth): define bootstrap and session expiry contracts 2026-07-27 20:44:31 +02:00
alex 88869052f2 style(v2): normalize repository safety test 2026-07-27 20:28:25 +02:00
alex b4a8201826 docs(v2): record M3 repository evidence 2026-07-27 20:26:44 +02:00
alex ee98030ecd style(v2): normalize repository integration formatting 2026-07-27 20:25:54 +02:00
alex 448fa98629 feat(v2): enforce repository safety invariants 2026-07-27 20:25:27 +02:00
alex 1115f63879 test(v2): define repository safety contracts 2026-07-27 20:23:16 +02:00
alex 8e50d51733 style(v2): organize repository test imports 2026-07-27 20:20:18 +02:00
alex df62536773 feat(v2): initialize allowlisted repositories 2026-07-27 20:19:51 +02:00
alex e529ad60fa test(v2): define repository initialization contract 2026-07-27 20:17:59 +02:00
alex b823c6edb7 feat(v2): add secure control plane 2026-07-27 20:13:08 +02:00
alex cb864bcac5 test(v2): define secure control-plane contracts 2026-07-27 19:56:01 +02:00
alex 8b831fc985 style(v2): normalize migration test formatting 2026-07-27 19:46:33 +02:00
alex 15f8794029 fix(v2): enforce M1 runtime persistence invariants 2026-07-27 19:42:41 +02:00
alex 45a526a1e9 test(v2): prove runtime dispatch and persistence invariants 2026-07-27 19:34:14 +02:00
alex 8d1de81f2b feat(v2): add validated runtime and metadata store 2026-07-27 19:24:49 +02:00
alex 8f784848d7 test(v2): define runtime and metadata contracts 2026-07-27 19:06:14 +02:00
alex a0d081a4b4 fix(v2): enforce hardened M0 contracts 2026-07-27 19:00:09 +02:00
alex 92f2aaa27f test(v2): harden protocol and clean-break contracts 2026-07-27 18:52:47 +02:00
alex 791f4526f9 chore(v2): establish protocol and test foundation 2026-07-27 18:44:47 +02:00
alex 33b0c21292 test(v2): define protocol and clean-break contracts 2026-07-27 18:29:43 +02:00
alex ae98f383d6 docs(v2): define reimplementation design and plan 2026-07-27 18:23:47 +02:00
alex 9f59540686 cleaned up 2026-06-11 14:55:34 +02:00
alex cc694e71b4 feat: dockerize app and convert to pyproject setup
- Add pyproject.toml with proper metadata and dependency groups
- Create multi-stage Dockerfile for backend
- Add docker-compose.yml with dev/prod profiles
- Create frontend Dockerfile with nginx
- Add .dockerignore for optimized builds
- Update README with Docker instructions and troubleshooting
- Remove requirements.txt in favor of pyproject.toml
- Ensure data persistence with Docker volumes
2026-05-11 22:50:11 +02:00
alex abeb64637e docs: add comprehensive README and update .gitignore 2026-05-11 22:05:03 +02:00
alex 1978602da6 feat(frontend): add static file serving for production build 2026-05-11 22:04:26 +02:00
alex 55ec0687a5 feat(retention): add count-based and days-based retention policies 2026-05-11 22:03:50 +02:00
alex a4a16655b9 feat(scheduler): add APScheduler integration with cron support 2026-05-11 22:03:03 +02:00
alex 4d2c21a42e feat(adapters): add SSH and database adapters 2026-05-11 22:02:25 +02:00
alex b4a22a1c44 feat(routers): add executions, backups, and settings endpoints 2026-05-11 22:01:31 +02:00
alex b2d196498b feat(dashboard): add GET /api/dashboard/ stats endpoint
- Active jobs count, total backups, storage used
- Recent failures (24h) and last 10 executions
- Uses DashboardStats schema
2026-05-11 21:56:02 +02:00
alex 66f9de17c5 feat(frontend): add Dashboard, Backups, and Settings pages
- Create Dashboard page with stats cards and recent activity feed
- Create Backups page with job listing table, create modal, and actions
- Create Settings page with tabs for General, Notifications, Security, Logs
- Update App.tsx to use new page components
- Add delete method to jobsApi in client.ts
- TypeScript compiles cleanly
2026-05-11 21:54:36 +02:00
alex 99eeffc810 chore: add .gitignore and remove node_modules from tracking 2026-05-11 21:48:38 +02:00
alex d3b92593d5 feat(frontend): add API client, layout, and routing
- Create Axios-based API client with sources, jobs, and dashboard endpoints
- Add responsive Layout component with navigation sidebar
- Set up React Router with Dashboard, Backups, and Settings routes
- Configure TanStack Query provider with default options
- Use lucide-react for navigation icons
2026-05-11 21:47:20 +02:00
alex 7676438466 fix(tests): resolve lazy loading errors in test_engine.py
Add await db.refresh() calls with relationship attributes before accessing
test_job.executions and execution.backups in async test context.

Fixes lazy loading errors in:
- test_execute_full_backup
- test_execute_incremental_without_full
2026-05-11 21:40:52 +02:00
alex ed64b5ff62 fix: use @pytest_asyncio.fixture for async fixtures in test_engine.py 2026-05-11 21:39:12 +02:00
alex 19987d68a0 refactor(tests): use client fixture for test isolation
Replace all AsyncClient instantiations in test_sources.py and test_jobs.py
with the shared  fixture from conftest.py. This ensures proper test
database isolation via dependency overrides.

- Remove imports of httpx AsyncClient, ASGITransport, and app
- Add client fixture parameter to all test functions
- Remove async with blocks around HTTP requests
- test_engine.py already uses db fixture correctly, no changes needed
2026-05-11 21:38:15 +02:00
alex 58c4bc7f30 fix: use ASGITransport for httpx AsyncClient in tests
- httpx.AsyncClient requires ASGITransport for FastAPI app testing
- Updated all test files to use correct API
2026-05-11 21:28:07 +02:00
alex 55ba9b62d6 fix: improve test isolation and configuration
- Use in-memory SQLite for tests to prevent conflicts
- Add try/finally for robust test cleanup
- Make database URL configurable via env var
- Make CORS origins configurable via env var
- Make SQL echo configurable via env var
2026-05-11 21:25:35 +02:00
alex 878e303cc6 feat: setup FastAPI main app with routers and test fixtures
- Main FastAPI app with all routers included
- CORS middleware for frontend
- Test fixtures with in-memory SQLite database
- Placeholder routers for future tasks
2026-05-11 21:22:55 +02:00
alex 955ed9db49 fix: jobs router session handling, schedule creation, and tests
- Use new session in background task for thread safety
- Fix schedule creation to associate with job
- Fix schedule endpoint return type
- Update tests to use AsyncClient
- Add missing tests for list, get, delete, schedule
- Add updated_at field to Schedule model
2026-05-11 21:20:31 +02:00
alex 1f1ed72494 feat: add jobs CRUD API with manual execution
- Full CRUD for backup jobs
- Manual job trigger endpoint with background execution
- Schedule creation endpoint
- Tests for job creation, update, execution, and 404 cases
2026-05-11 21:12:01 +02:00
alex 810aae1b6e test: add update and 404 tests for sources API 2026-05-11 21:01:41 +02:00
alex 0d0f41d616 feat: add sources CRUD API with tests
- Full CRUD endpoints for backup sources
- Pydantic validation for source types
- pytest tests for create, list, get, delete
2026-05-11 20:59:11 +02:00
alex b69c098cab feat: add backup engine with full/incremental support
- BackupEngine class executes jobs and creates backups
- Automatic fallback from incremental to full if no parent exists
- SHA-256 checksum calculation for integrity
- Tests for full backup and incremental fallback
2026-05-11 20:56:06 +02:00
alex f925ae094c feat: add source adapter base class and local filesystem adapter
- Abstract SourceAdapter with FileInfo dataclass
- LocalAdapter for filesystem backups
- Adapter factory for extensibility
2026-05-11 20:52:31 +02:00
alex 83c3075de0 fix: update pydantic schemas to v2 syntax
- Replace Config class with ConfigDict
- Add cron expression validation
- Add missing updated_at to Schedule
- Add JobExecutionUpdate schema
2026-05-11 20:50:35 +02:00
alex 76cf5054be feat: add pydantic schemas for API validation 2026-05-11 20:47:48 +02:00
alex caf5bcb172 fix: address database model quality issues
- Fix mutable default arguments with lambda
- Add indexes on foreign keys
- Replace deprecated datetime.utcnow
- Add __repr__ methods for debugging
- Remove redundant primary key indexes
2026-05-11 20:46:03 +02:00
alex 13488f7b21 feat: add database models and alembic setup
- SQLAlchemy async models for sources, jobs, schedules, executions, backups, settings
- Alembic migration configuration
2026-05-11 20:43:23 +02:00
alex 3deab5fdf3 fix: resolve frontend code quality issues
- Create missing tsconfig.node.json for Vite config compilation
- Create postcss.config.js for Tailwind CSS processing
- Add test dependencies (vitest, testing-library, jsdom)
- Remove missing /vite.svg favicon reference from index.html
2026-05-11 20:39:05 +02:00
alex eff41bd801 chore: setup project structure with dependencies 2026-05-11 20:28:22 +02:00
alex 4989652c5d docs: add comprehensive implementation plan
- 20 tasks covering backend, frontend, adapters, scheduler
- File structure with exact paths
- Complete code in every step
- Self-review with spec coverage verification
2026-05-11 20:23:54 +02:00
alex 56b68d7e57 docs: add backup tool design specification
- Architecture: Python FastAPI backend + React frontend
- Data model: 6 core tables (sources, jobs, schedules, executions, backups, settings)
- Backup flow: 7-step execution with incremental support
- Error handling: Retry logic, partial backup handling, retention policies
- Extensibility: Adapter pattern for sources and storage backends
2026-05-11 20:10:13 +02:00
223 changed files with 36456 additions and 1 deletions
+77
View File
@@ -0,0 +1,77 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
.venv/
env/
ENV/
# Database
*.db
*.sqlite
*.sqlite3
# Frontend
node_modules/
frontend/dist/
frontend/build/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Environment
.env
.env.local
# Git
.git/
.gitignore
# Documentation
*.md
!README.md
# Tests
tests/
!tests/ssh-fixture/
!tests/ssh-fixture/Dockerfile
!tests/ssh-fixture/sshd_config
.pytest_cache/
.coverage
# Backup tool data
backups/
test.db
backup_tool.db
+70
View File
@@ -0,0 +1,70 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
.venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment files
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite
*.sqlite3
# Frontend
frontend/node_modules/
frontend/dist/
frontend/test-results/
frontend/yarn.lock
frontend/pnpm-lock.yaml
# Logs
*.log
logs/
# Testing
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Backup tool specific
backups/
.pi-subagents/
+1
View File
@@ -0,0 +1 @@
22.17.1
+149
View File
@@ -0,0 +1,149 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+154
View File
@@ -0,0 +1,154 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+170
View File
@@ -0,0 +1,170 @@
---
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+103
View File
@@ -0,0 +1,103 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
+1
View File
@@ -0,0 +1 @@
3.12.11
@@ -0,0 +1,113 @@
<h2>Architecture Overview</h2>
<p class="subtitle">High-level system design for the backup tool</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">System Architecture</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 20px;">
<!-- Frontend Layer -->
<div style="border: 2px solid #4CAF50; border-radius: 8px; padding: 15px; background: #f8fff8;">
<div class="label" style="color: #4CAF50;">Frontend Layer</div>
<div style="display: flex; gap: 15px; margin-top: 10px;">
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Dashboard View</strong><br>
<small>Overview, stats, recent backups</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Backup View</strong><br>
<small>Jobs, sources, schedules</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Settings View</strong><br>
<small>Config, storage, notifications</small>
</div>
</div>
</div>
<!-- API Layer -->
<div style="text-align: center; color: #666; font-size: 24px;">↓ REST API ↓</div>
<!-- Backend Layer -->
<div style="border: 2px solid #2196F3; border-radius: 8px; padding: 15px; background: #f0f8ff;">
<div class="label" style="color: #2196F3;">Backend Layer (Python)</div>
<div style="display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>API Server</strong><br>
<small>FastAPI/Flask</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Backup Engine</strong><br>
<small>Core logic</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Scheduler</strong><br>
<small>APScheduler</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Source Adapters</strong><br>
<small>SSH, DB, Local</small>
</div>
</div>
</div>
<!-- Data Layer -->
<div style="text-align: center; color: #666; font-size: 24px;">↓ Persistence ↓</div>
<div style="border: 2px solid #FF9800; border-radius: 8px; padding: 15px; background: #fff8f0;">
<div class="label" style="color: #FF9800;">Data Layer</div>
<div style="display: flex; gap: 15px; margin-top: 10px;">
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>SQLite Database</strong><br>
<small>Jobs, sources, history, config</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>File Storage</strong><br>
<small>Backup archives</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Key Design Principles</h3>
<ul>
<li><strong>Source-agnostic</strong>: Adapter pattern for different backup sources (local, SSH, future cloud)</li>
<li><strong>Job-driven</strong>: Everything is a job - manual or scheduled, full or incremental</li>
<li><strong>SQLite for simplicity</strong>: Single file database, no external DB server needed</li>
<li><strong>REST API</strong>: Clean separation between frontend and backend</li>
<li><strong>Extensible</strong>: Plugin architecture for new source types and storage backends</li>
</ul>
</div>
<div class="section">
<h3>Technology Stack</h3>
<div style="display: flex; gap: 20px;">
<div style="flex: 1;">
<h4>Backend</h4>
<ul>
<li>Python 3.11+</li>
<li>FastAPI (async, auto-docs)</li>
<li>SQLAlchemy + Alembic</li>
<li>APScheduler (job scheduling)</li>
<li>Paramiko (SSH)</li>
<li>rsync/libsync (incremental backups)</li>
</ul>
</div>
<div style="flex: 1;">
<h4>Frontend</h4>
<ul>
<li>React 18+ with TypeScript</li>
<li>TanStack Query (data fetching)</li>
<li>React Router (navigation)</li>
<li>Tailwind CSS (styling)</li>
<li>Recharts (dashboard charts)</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
<h2>Backup Flow</h2>
<p class="subtitle">How a backup job executes from trigger to completion</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">Backup Execution Flow</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 0;">
<!-- Step 1 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #2196F3;">
<div style="background: #2196F3; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">1</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Trigger</div>
<div style="color: #666; font-size: 14px;">Manual click or scheduled cron trigger fires</div>
</div>
</div>
<div style="text-align: center; color: #2196F3; font-size: 20px;"></div>
<!-- Step 2 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #FF9800;">
<div style="background: #FF9800; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">2</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Create Execution Record</div>
<div style="color: #666; font-size: 14px;">Insert row into job_executions with status "pending"</div>
</div>
</div>
<div style="text-align: center; color: #FF9800; font-size: 20px;"></div>
<!-- Step 3 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f8fff8; border-radius: 8px; border-left: 4px solid #4CAF50;">
<div style="background: #4CAF50; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">3</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Connect to Source</div>
<div style="color: #666; font-size: 14px;">Establish connection (local filesystem, SSH, or database)</div>
</div>
</div>
<div style="text-align: center; color: #4CAF50; font-size: 20px;"></div>
<!-- Step 4 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #faf0ff; border-radius: 8px; border-left: 4px solid #9C27B0;">
<div style="background: #9C27B0; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">4</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Determine Backup Strategy</div>
<div style="color: #666; font-size: 14px;">Full backup OR incremental based on last successful backup</div>
</div>
</div>
<div style="text-align: center; color: #9C27B0; font-size: 20px;"></div>
<!-- Step 5 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #2196F3;">
<div style="background: #2196F3; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">5</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Execute Backup</div>
<div style="color: #666; font-size: 14px;">Transfer data, compress, encrypt (if enabled), calculate checksums</div>
</div>
</div>
<div style="text-align: center; color: #2196F3; font-size: 20px;"></div>
<!-- Step 6 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f8fff8; border-radius: 8px; border-left: 4px solid #4CAF50;">
<div style="background: #4CAF50; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">6</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Verify & Store</div>
<div style="color: #666; font-size: 14px;">Verify checksum, store metadata in backups table, update execution status</div>
</div>
</div>
<div style="text-align: center; color: #4CAF50; font-size: 20px;"></div>
<!-- Step 7 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #FF9800;">
<div style="background: #FF9800; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">7</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Cleanup & Retention</div>
<div style="color: #666; font-size: 14px;">Apply retention policy, remove old backups, update storage stats</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Error Handling</h3>
<ul>
<li><strong>Connection failures</strong>: Retry 3x with exponential backoff, then mark as failed</li>
<li><strong>Partial backups</strong>: Mark as failed if any source file fails; keep partial for inspection</li>
<li><strong>Storage full</strong>: Check before starting, alert if < 10% free space</li>
<li><strong>Checksum mismatch</strong>: Delete corrupted backup, retry once, alert admin</li>
<li><strong>Concurrent jobs</strong>: Queue or cancel based on resource availability</li>
</ul>
</div>
<div class="section">
<h3>Extensibility Design</h3>
<p>The system is designed for easy extension:</p>
<ul>
<li><strong>Source adapters</strong>: Abstract base class for new source types (S3, Azure, etc.)</li>
<li><strong>Storage backends</strong>: Pluggable storage interface for cloud destinations</li>
<li><strong>Notification channels</strong>: Webhook, email, Slack plugins for alerts</li>
<li><strong>Compression algorithms</strong>: Configurable (gzip, bzip2, zstd, none)</li>
</ul>
</div>
@@ -0,0 +1,128 @@
<h2>Data Model</h2>
<p class="subtitle">SQLite database schema for the backup tool</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">Entity Relationship Diagram</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 15px; font-family: monospace; font-size: 14px;">
<!-- Sources -->
<div style="border: 2px solid #4CAF50; border-radius: 6px; padding: 12px; background: #f8fff8;">
<div style="font-weight: bold; color: #4CAF50; margin-bottom: 8px;">📁 sources</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>name</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>type</span> <span style="color: #666;">TEXT (local|ssh|database)</span>
<span> </span> <span>config</span> <span style="color: #666;">JSON</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Jobs -->
<div style="border: 2px solid #2196F3; border-radius: 6px; padding: 12px; background: #f0f8ff;">
<div style="font-weight: bold; color: #2196F3; margin-bottom: 8px;">⚙️ jobs</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>name</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>source_id</span> <span style="color: #666;">INTEGER → sources</span>
<span> </span> <span>strategy</span> <span style="color: #666;">TEXT (full|incremental)</span>
<span> </span> <span>destination_path</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>exclude_patterns</span> <span style="color: #666;">JSON</span>
<span> </span> <span>enabled</span> <span style="color: #666;">BOOLEAN</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Schedules -->
<div style="border: 2px solid #FF9800; border-radius: 6px; padding: 12px; background: #fff8f0;">
<div style="font-weight: bold; color: #FF9800; margin-bottom: 8px;">📅 schedules</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>job_id</span> <span style="color: #666;">INTEGER → jobs</span>
<span> </span> <span>cron_expression</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>enabled</span> <span style="color: #666;">BOOLEAN</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Job Executions -->
<div style="border: 2px solid #9C27B0; border-radius: 6px; padding: 12px; background: #faf0ff;">
<div style="font-weight: bold; color: #9C27B0; margin-bottom: 8px;">▶️ job_executions</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>job_id</span> <span style="color: #666;">INTEGER → jobs</span>
<span> </span> <span>status</span> <span style="color: #666;">TEXT (pending|running|success|failed|cancelled)</span>
<span> </span> <span>started_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>completed_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>bytes_processed</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>bytes_backed_up</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>error_message</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>triggered_by</span> <span style="color: #666;">TEXT (manual|schedule)</span>
</div>
</div>
<!-- Backups -->
<div style="border: 2px solid #607D8B; border-radius: 6px; padding: 12px; background: #f5f5f5;">
<div style="font-weight: bold; color: #607D8B; margin-bottom: 8px;">💾 backups</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>execution_id</span> <span style="color: #666;">INTEGER → job_executions</span>
<span> </span> <span>storage_path</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>size_bytes</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>checksum</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>type</span> <span style="color: #666;">TEXT (full|incremental)</span>
<span> </span> <span>parent_backup_id</span> <span style="color: #666;">INTEGER → backups (for incremental)</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Settings -->
<div style="border: 2px solid #795548; border-radius: 6px; padding: 12px; background: #faf5f0;">
<div style="font-weight: bold; color: #795548; margin-bottom: 8px;">⚙️ settings</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>key</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>value</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Key Relationships</h3>
<ul>
<li><strong>sources → jobs</strong>: One source can have many jobs (different backup strategies for the same data)</li>
<li><strong>jobs → schedules</strong>: One job can have one schedule (1:1 for simplicity)</li>
<li><strong>jobs → job_executions</strong>: One job has many executions over time</li>
<li><strong>job_executions → backups</strong>: Each execution produces one or more backup archives</li>
<li><strong>backups → backups</strong>: Self-referencing for incremental chain (parent_backup_id)</li>
</ul>
</div>
<div class="section">
<h3>Design Decisions</h3>
<div class="pros-cons">
<div class="pros">
<h4>Pros</h4>
<ul>
<li>Flexible source config via JSON (extensible for new source types)</li>
<li>Execution history tracking for audit and debugging</li>
<li>Incremental backup chain support</li>
<li>Simple key-value settings (easy to extend)</li>
</ul>
</div>
<div class="cons">
<h4>Cons>/h4>
<ul>
<li>JSON config loses some type safety (mitigated by validation)</li>
<li>No built-in backup retention policy table (can be added later)</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,174 @@
<h2>Frontend Views</h2>
<p class="subtitle">UI mockups for the three main views</p>
<div class="section">
<h3>Dashboard View</h3>
<div class="mockup">
<div class="mockup-header">Dashboard</div>
<div class="mockup-body">
<div class="mock-nav">
<span style="font-weight: bold;">🛡️ Backup Tool</span> | Dashboard | Backups | Settings
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0;">
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #4CAF50;">12</div>
<div style="color: #666;">Active Jobs</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #2196F3;">847</div>
<div style="color: #666;">Total Backups</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #FF9800;">2.4 TB</div>
<div style="color: #666;">Storage Used</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #f44336;">1</div>
<div style="color: #666;">Failed (24h)</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px;">
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 10px;">Recent Backup Activity</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>📁 Home Server Files</span>
<span style="color: #4CAF50;">✓ Success</span>
<span style="color: #666;">2 hours ago</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>🗄️ Production DB</span>
<span style="color: #4CAF50;">✓ Success</span>
<span style="color: #666;">5 hours ago</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>📁 Cloud Storage Sync</span>
<span style="color: #f44336;">✗ Failed</span>
<span style="color: #666;">1 day ago</span>
</div>
</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 10px;">Storage Overview</div>
<div style="height: 150px; background: linear-gradient(to bottom, #e3f2fd 0%, #bbdefb 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
[Pie Chart: Storage by Job]
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Backup View</h3>
<div class="mockup">
<div class="mockup-header">Backups</div>
<div class="mockup-body">
<div class="mock-nav">
<span>🛡️ Backup Tool</span> | Dashboard | <span style="font-weight: bold;">Backups</span> | Settings
</div>
<div style="display: flex; justify-content: space-between; margin: 20px 0;">
<div>
<button class="mock-button">+ New Job</button>
<button class="mock-button">+ New Source</button>
</div>
<div>
<input class="mock-input" placeholder="Search jobs..." style="width: 200px;">
</div>
</div>
<div style="border: 1px solid #ddd; border-radius: 6px; overflow: hidden;">
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; background: #f5f5f5; padding: 10px; font-weight: bold;">
<div>Job Name</div>
<div>Source</div>
<div>Strategy</div>
<div>Schedule</div>
<div>Last Run</div>
<div>Status</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>🏠 Home Server Files</div>
<div>Local Path</div>
<div><span style="background: #e3f2fd; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Incremental</span></div>
<div>Daily at 2 AM</div>
<div>2 hours ago</div>
<div><span style="color: #4CAF50;">● Active</span></div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>🗄️ Production DB</div>
<div>PostgreSQL</div>
<div><span style="background: #fff3e0; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Full</span></div>
<div>Hourly</div>
<div>5 hours ago</div>
<div><span style="color: #4CAF50;">● Active</span></div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>☁️ Cloud Storage Sync</div>
<div>SSH (AWS)</div>
<div><span style="background: #e3f2fd; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Incremental</span></div>
<div>Weekly (Sun)</div>
<div>1 day ago</div>
<div><span style="color: #f44336;">● Failed</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Settings View</h3>
<div class="mockup">
<div class="mockup-header">Settings</div>
<div class="mockup-body">
<div class="mock-nav">
<span>🛡️ Backup Tool</span> | Dashboard | Backups | <span style="font-weight: bold;">Settings</span>
</div>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 20px; margin-top: 20px;">
<div style="border-right: 1px solid #ddd; padding-right: 20px;">
<div style="padding: 10px; background: #e3f2fd; border-radius: 4px; font-weight: bold;">General</div>
<div style="padding: 10px;">Storage</div>
<div style="padding: 10px;">Notifications</div>
<div style="padding: 10px;">Security</div>
<div style="padding: 10px;">Logs</div>
</div>
<div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Default Backup Location</div>
<input class="mock-input" value="/var/backups/backup-tool" style="width: 300px;">
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Retention Policy</div>
<select class="mock-input" style="width: 200px;">
<option>Keep all backups</option>
<option>Keep last 30 days</option>
<option selected>Keep last 10 versions</option>
</select>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Compression</div>
<label><input type="checkbox" checked> Enable compression (gzip)</label>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Encryption</div>
<label><input type="checkbox"> Enable AES-256 encryption</label>
</div>
<div>
<button class="mock-button">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1778524615786}
@@ -0,0 +1,6 @@
{"type":"server-started","port":63865,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63865","screen_dir":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content","state_dir":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/state"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/architecture-overview.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/data-model.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/frontend-views.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/backup-flow.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -0,0 +1 @@
269339
+25
View File
@@ -0,0 +1,25 @@
# Agent Operating Instructions
## Continuous milestone execution
When the user asks to continue, work autonomously through the active milestone
and its verification. Do **not** send progress-only, acknowledgement, empty, or
status final responses. Reply only when:
1. the user asks for status;
2. a real product, security, credentials, or destructive-action decision needs
the user's input; or
3. the requested milestone is fully implemented and verified.
Use one foreground milestone batch where possible. If work must run in a
background subagent, avoid notifying the user manually; inspect and verify the
result before replying at the same completion boundary.
A chat turn still necessarily ends after a model response. This file prevents
unnecessary model-generated completion messages; it cannot suppress
harness-generated tool or subagent notifications.
## Verification
Before claiming a milestone boundary, run `make check` and record focused
acceptance evidence under `docs/release/`.
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
# Pinned by manifest digest; update tag and digest together.
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /build
COPY backend/pyproject.toml ./pyproject.toml
COPY backend/src ./src
RUN python -m pip install --upgrade pip==25.3 && \
python -m pip wheel --wheel-dir /wheels .
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS runtime
ENV PATH="/opt/venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
BACKUP_TOOL_ALEMBIC_ROOT=/app
RUN groupadd --gid 10001 backup-tool && \
useradd --uid 10001 --gid backup-tool --create-home --home-dir /home/backup-tool \
--shell /usr/sbin/nologin backup-tool && \
python -m venv /opt/venv && \
install --directory --owner=backup-tool --group=backup-tool --mode=0750 \
/var/lib/backup-tool \
/var/lib/backup-tool/repositories \
/var/lib/backup-tool/restores \
/run/backup-tool
COPY --from=builder /wheels /wheels
RUN python -m pip install --no-index --find-links=/wheels backup-tool==2.0.0.dev0 && \
rm -rf /wheels
COPY --chown=backup-tool:backup-tool backend/alembic.ini /app/alembic.ini
COPY --chown=backup-tool:backup-tool backend/alembic /app/alembic
WORKDIR /app
USER backup-tool:backup-tool
ENTRYPOINT ["backup-tool"]
CMD ["web"]
+52
View File
@@ -0,0 +1,52 @@
PYTHON ?= .venv/bin/python
BOOTSTRAP_PYTHON ?= python3
NPM ?= npm
.PHONY: setup install test-fast test-integration test-fault test-security test-e2e test-ssh-integration lint typecheck frontend-build check-v1-absent check-openapi check
setup:
test -x $(PYTHON) || $(BOOTSTRAP_PYTHON) -m venv .venv
$(PYTHON) -m pip install --upgrade pip
$(PYTHON) -m pip install -e 'backend[dev]'
$(NPM) --prefix frontend ci
install: setup
test-fast:
$(PYTHON) -m pytest tests/unit tests/contract -q
test-integration:
$(PYTHON) -m pytest tests/integration -q
test-fault:
$(PYTHON) -m pytest tests/fault -q
test-security:
$(PYTHON) -m pytest tests/security -q
test-e2e:
BACKUP_TOOL_COMPOSE_E2E=1 $(PYTHON) -m pytest tests/e2e/test_compose_v2.py -q
test-ssh-integration:
BACKUP_TOOL_SSH_INTEGRATION=1 $(PYTHON) -m pytest tests/integration/test_ssh_backup_restore.py -q
lint:
$(PYTHON) -m ruff check --config backend/pyproject.toml backend/src backend/alembic tests tools
$(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src backend/alembic tests tools
typecheck:
$(PYTHON) -m mypy --config-file backend/pyproject.toml
$(NPM) --prefix frontend run typecheck
frontend-build:
$(NPM) --prefix frontend run build
check-openapi:
$(PYTHON) tools/export_openapi.py
$(NPM) --prefix frontend run api:generate
git diff --exit-code -- openapi/v2.json frontend/src/api/generated
check-v1-absent:
$(PYTHON) tools/forbidden_v1_scan.py .
check: check-v1-absent check-openapi test-fast test-integration test-fault test-security lint typecheck frontend-build
+118 -1
View File
@@ -1,2 +1,119 @@
# backup-tool # Backup Tool v2
> A self-hosted, integrity-first backup appliance for local and hardened SSH sources.
Backup Tool creates signed, content-addressed backups, supports encrypted repositories and offline recovery, and runs as isolated web, scheduler, worker, migration, and admin roles. v2.0 supports **local** and **SSH** sources, local repositories, restore, encrypted recovery, email, and signed webhooks.
## What is supported
- Signed full and incremental backups, restore, retention, verification, and recovery.
- AES-256-GCM repository encryption with key epochs and offline Argon2id recovery bundles.
- Local sources and SSH sources using **private-key-only, pinned-host-key, forced-SFTP chroot** access.
- Durable, at-least-once webhook and STARTTLS SMTP notifications.
- A generated OpenAPI TypeScript client and operator UI.
Never use an SSH source with a shell, password, agent forwarding, remote commands, or an unchrooted account. See [SSH source requirements](docs/runbooks/ssh-sources.md).
## Quick local verification
Prerequisites: Docker Compose, Python 3.123.14, Node/npm, and a working virtual environment.
```sh
make setup
make check
make test-e2e
make test-ssh-integration
```
`make test-e2e` creates temporary test-only keys and source data, starts the complete Compose stack, checks readiness/metrics/restart behavior, and tears it down. `make test-ssh-integration` builds a separate forced-SFTP fixture and verifies key-authenticated SSH probe, backup, verification, and restore.
## Run locally with Docker Compose
The production Compose topology binds the proxy to `127.0.0.1:8080`; put a TLS reverse proxy in front of it for remote access.
```sh
mkdir -p secrets sources
head -c 32 /dev/urandom > secrets/master.key
chmod 600 secrets/master.key
# The container runs as UID 10001; grant that UID read access to the key.
chown 10001:10001 secrets/master.key
# Put test source data below ./sources, then start the isolated roles.
docker compose build --pull
docker compose run --rm migrate upgrade
docker compose up -d
curl -fsS http://127.0.0.1:8080/readyz
curl -fsS http://127.0.0.1:8080/metrics
```
On a fresh non-loopback deployment, set `BACKUP_TOOL_PUBLIC_BASE_URL` to the externally reachable HTTPS URL and set `BACKUP_TOOL_BOOTSTRAP_SECRET` before first setup. Never expose a fresh setup endpoint without bootstrap protection.
Stop the local stack with:
```sh
docker compose down
# Add --volumes only when intentionally discarding local metadata and repositories.
```
## Operator workflow
1. Open `http://127.0.0.1:8080` and create the first administrator.
2. Create a local or encrypted repository.
3. Create a local source, or configure a hardened SSH source as documented in [SSH sources](docs/runbooks/ssh-sources.md).
4. Create a job, run a probe, enqueue a backup, verify it, then perform a test restore.
5. Configure notification subscriptions and test them before relying on delivery.
6. Export and validate a recovery bundle after encrypted repository creation and each key rotation.
The browser exposes recovery status and the CLI procedure only; it never transfers recovery bundles or passphrases.
## Configuration
Compose supplies the core runtime variables. The important host paths are:
| Setting | Compose value | Purpose |
| --- | --- | --- |
| `BACKUP_TOOL_MASTER_KEY_FILE` | `/run/backup-tool-secrets/master.key` | Service-owned `0600` master key |
| `BACKUP_TOOL_SOURCES_DIR` | `./sources` | Read-only local-source bind mount |
| `BACKUP_TOOL_PUBLIC_BASE_URL` | `http://localhost:8080` | External URL and bootstrap policy |
| `BACKUP_TOOL_PORT` | `8080` | Loopback proxy port |
Use absolute, allowlisted paths for repositories, sources, and restores. The service rejects unsafe paths, symlinks where they violate the contract, missing key files, and non-current schemas.
## Operations and security
- [Observability](docs/runbooks/observability.md): readiness, liveness, metrics, and alert response.
- [Upgrade and rollback](docs/runbooks/upgrade.md)
- [Disaster recovery](docs/runbooks/disaster-recovery.md)
- [Recovery bundles](docs/runbooks/recovery-bundle.md)
- [Repository keys](docs/runbooks/keys.md)
- [Notifications](docs/runbooks/notifications.md)
- [SSH sources](docs/runbooks/ssh-sources.md)
- [Security policies](docs/README.md#security-boundaries)
## Development
```sh
make setup
make test-fast
make test-integration
make test-fault
make test-security
npm --prefix frontend test -- --run
npm --prefix frontend exec playwright test
```
OpenAPI and the generated browser client are committed artifacts:
```sh
.venv/bin/python tools/export_openapi.py --check openapi/v2.json
npm --prefix frontend run api:generate
git diff --exit-code -- openapi/v2.json frontend/src/api/generated
```
## Release evidence
Milestone evidence, the SBOM, provenance, and synthetic scale report live in [docs/release](docs/release/). M15 certification is synthetic metadata certification on the reference CI host; it does not claim a physical 10 TiB transfer.
## License
MIT — see [LICENSE](LICENSE).
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = %(here)s/src
path_separator = os
sqlalchemy.url = sqlite+aiosqlite:////var/lib/backup-tool/metadata.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import importlib
import os
from logging.config import fileConfig
from typing import Any
from alembic import context
from sqlalchemy import create_engine, event, pool
from sqlalchemy.engine import URL, make_url
models = importlib.import_module("backup_tool.db.models")
sqlite_runtime = importlib.import_module("backup_tool.db.sqlite")
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = models.Base.metadata
def configured_url() -> str:
raw_url = os.environ.get("BACKUP_TOOL_DATABASE_URL") or config.get_main_option("sqlalchemy.url")
if raw_url is None:
raise RuntimeError("sqlalchemy.url is required")
return raw_url
def busy_timeout_ms() -> int:
raw_value = os.environ.get(
"BACKUP_TOOL_SQLITE_BUSY_TIMEOUT_MS", str(sqlite_runtime.DEFAULT_BUSY_TIMEOUT_MS)
)
try:
value = int(raw_value)
except ValueError as error:
raise RuntimeError("SQLite busy timeout must be an integer") from error
if value < 1_000 or value > 120_000:
raise RuntimeError("SQLite busy timeout must be between 1000 and 120000 ms")
return value
def synchronous_url(raw_url: str) -> URL:
url = make_url(raw_url)
if url.drivername != "sqlite+aiosqlite":
raise RuntimeError("v2 migrations require sqlite+aiosqlite")
return url.set(drivername="sqlite")
def run_migrations_offline() -> None:
url = synchronous_url(configured_url())
context.configure(
url=url.render_as_string(hide_password=False),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
timeout = busy_timeout_ms()
engine = create_engine(
synchronous_url(configured_url()),
poolclass=pool.NullPool,
connect_args=sqlite_runtime.sqlite_connect_args(timeout),
)
@event.listens_for(engine, "connect")
def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None:
sqlite_runtime.configure_sqlite_connection(dbapi_connection)
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
engine.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,548 @@
"""v2 baseline
Revision ID: 0001_v2_baseline
Revises:
Create Date: 2026-07-27 19:15:52.319338
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001_v2_baseline"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"repositories",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("root", sa.Text(), nullable=False),
sa.Column("format_version", sa.Integer(), nullable=False),
sa.Column("compression", sa.String(length=32), nullable=False),
sa.Column("encryption", sa.String(length=32), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"state IN ('active','archived','unavailable')", name=op.f("ck_repositories_state")
),
sa.CheckConstraint(
"format_version > 0", name=op.f("ck_repositories_format_version_positive")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_repositories")),
sa.UniqueConstraint("name", name=op.f("uq_repositories_name")),
sa.UniqueConstraint("root", name=op.f("uq_repositories_root")),
)
op.create_table(
"secrets",
sa.Column("ciphertext", sa.LargeBinary(), nullable=False),
sa.Column("key_id", sa.String(length=255), nullable=False),
sa.Column("purpose", sa.String(length=64), nullable=False),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint("version > 0", name=op.f("ck_secrets_version_positive")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_secrets")),
)
op.create_table(
"sources",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("public_config", sa.JSON(), nullable=False),
sa.Column("secret_refs", sa.JSON(), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("last_probe", sa.JSON(), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"kind IN ('local','sftp','postgresql','mysql')", name=op.f("ck_sources_kind")
),
sa.CheckConstraint(
"state IN ('active','archived','unavailable')", name=op.f("ck_sources_state")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_sources")),
sa.UniqueConstraint("name", name=op.f("uq_sources_name")),
)
op.create_table(
"users",
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint("state IN ('active','disabled')", name=op.f("ck_users_state")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
sa.UniqueConstraint("username", name=op.f("uq_users_username")),
)
op.create_table(
"api_tokens",
sa.Column("owner_id", sa.String(length=36), nullable=False),
sa.Column("token_hash", sa.Text(), nullable=False),
sa.Column("scopes", sa.JSON(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
name=op.f("fk_api_tokens_owner_id_users"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_api_tokens")),
sa.UniqueConstraint("token_hash", name=op.f("uq_api_tokens_token_hash")),
)
op.create_index("ix_api_tokens_owner_id", "api_tokens", ["owner_id"], unique=False)
op.create_table(
"audit_events",
sa.Column("actor_id", sa.String(length=36), nullable=True),
sa.Column("action", sa.String(length=255), nullable=False),
sa.Column("resource_type", sa.String(length=64), nullable=False),
sa.Column("resource_id", sa.String(length=36), nullable=True),
sa.Column("outcome", sa.String(length=32), nullable=False),
sa.Column("request_id", sa.String(length=36), nullable=False),
sa.Column("details", sa.JSON(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("id", sa.String(length=36), nullable=False),
sa.CheckConstraint(
"outcome IN ('success','failure','denied')", name=op.f("ck_audit_events_outcome")
),
sa.ForeignKeyConstraint(
["actor_id"],
["users.id"],
name=op.f("fk_audit_events_actor_id_users"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_events")),
)
op.create_index("ix_audit_events_actor_id", "audit_events", ["actor_id"], unique=False)
op.create_index("ix_audit_events_created_at", "audit_events", ["created_at"], unique=False)
op.create_table(
"idempotency_records",
sa.Column("actor_id", sa.String(length=36), nullable=False),
sa.Column("key", sa.String(length=255), nullable=False),
sa.Column("operation", sa.String(length=255), nullable=False),
sa.Column("request_digest", sa.String(length=64), nullable=False),
sa.Column("response_resource_type", sa.String(length=64), nullable=False),
sa.Column("response_resource_id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("id", sa.String(length=36), nullable=False),
sa.ForeignKeyConstraint(
["actor_id"],
["users.id"],
name=op.f("fk_idempotency_records_actor_id_users"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_idempotency_records")),
sa.UniqueConstraint(
"actor_id", "key", "operation", name="uq_idempotency_actor_key_operation"
),
)
op.create_index(
"ix_idempotency_created_at", "idempotency_records", ["created_at"], unique=False
)
op.create_table(
"jobs",
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("source_id", sa.String(length=36), nullable=False),
sa.Column("repository_id", sa.String(length=36), nullable=False),
sa.Column("requested_mode", sa.String(length=32), nullable=False),
sa.Column("exclusions", sa.JSON(), nullable=False),
sa.Column("retention", sa.JSON(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("allow_empty", sa.Boolean(), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"requested_mode IN ('full','incremental')", name=op.f("ck_jobs_requested_mode")
),
sa.CheckConstraint("state IN ('active','archived')", name=op.f("ck_jobs_state")),
sa.ForeignKeyConstraint(
["repository_id"],
["repositories.id"],
name=op.f("fk_jobs_repository_id_repositories"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_id"],
["sources.id"],
name=op.f("fk_jobs_source_id_sources"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_jobs")),
sa.UniqueConstraint("name", name=op.f("uq_jobs_name")),
)
op.create_index("ix_jobs_repository_id", "jobs", ["repository_id"], unique=False)
op.create_index("ix_jobs_source_id", "jobs", ["source_id"], unique=False)
op.create_table(
"notification_subscriptions",
sa.Column("channel", sa.String(length=32), nullable=False),
sa.Column("event_filters", sa.JSON(), nullable=False),
sa.Column("destination_config", sa.JSON(), nullable=False),
sa.Column("secret_id", sa.String(length=36), nullable=True),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"channel IN ('webhook','email')", name=op.f("ck_notification_subscriptions_channel")
),
sa.CheckConstraint(
"state IN ('active','disabled','archived')",
name=op.f("ck_notification_subscriptions_state"),
),
sa.ForeignKeyConstraint(
["secret_id"],
["secrets.id"],
name=op.f("fk_notification_subscriptions_secret_id_secrets"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_subscriptions")),
)
op.create_table(
"notification_deliveries",
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("subscription_id", sa.String(length=36), nullable=False),
sa.Column("attempt", sa.Integer(), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("response_class", sa.String(length=64), nullable=True),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"state IN ('pending','delivered','retry','failed')",
name=op.f("ck_notification_deliveries_state"),
),
sa.CheckConstraint("attempt > 0", name=op.f("ck_notification_deliveries_attempt_positive")),
sa.ForeignKeyConstraint(
["subscription_id"],
["notification_subscriptions.id"],
name=op.f("fk_notification_deliveries_subscription_id_notification_subscriptions"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification_deliveries")),
sa.UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"),
)
op.create_index(
"ix_notification_deliveries_state_next",
"notification_deliveries",
["state", "next_attempt_at"],
unique=False,
)
op.create_table(
"schedules",
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("cron", sa.String(length=255), nullable=False),
sa.Column("timezone", sa.String(length=255), nullable=False),
sa.Column("misfire_grace_seconds", sa.Integer(), nullable=False),
sa.Column("overlap_policy", sa.String(length=32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("next_nominal_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_enqueue_outcome", sa.String(length=64), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"overlap_policy IN ('prohibit')", name=op.f("ck_schedules_overlap_policy")
),
sa.CheckConstraint(
"misfire_grace_seconds >= 0", name=op.f("ck_schedules_misfire_nonnegative")
),
sa.ForeignKeyConstraint(
["job_id"], ["jobs.id"], name=op.f("fk_schedules_job_id_jobs"), ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_schedules")),
sa.UniqueConstraint("job_id", name=op.f("uq_schedules_job_id")),
)
op.create_table(
"executions",
sa.Column("job_id", sa.String(length=36), nullable=False),
sa.Column("schedule_id", sa.String(length=36), nullable=True),
sa.Column("nominal_run_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("trigger", sa.String(length=32), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("attempt", sa.Integer(), nullable=False),
sa.Column("lease_owner", sa.String(length=255), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("heartbeat_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("progress", sa.JSON(), nullable=False),
sa.Column("reason_code", sa.String(length=64), nullable=True),
sa.Column("operator_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"state IN ('queued','preparing','running','verifying','committed',"
"'cancelling','cancelled','failed')",
name=op.f("ck_executions_state"),
),
sa.CheckConstraint(
"trigger IN ('manual','schedule','retry')", name=op.f("ck_executions_trigger")
),
sa.CheckConstraint("attempt > 0", name=op.f("ck_executions_attempt_positive")),
sa.ForeignKeyConstraint(
["job_id"], ["jobs.id"], name=op.f("fk_executions_job_id_jobs"), ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["schedule_id"],
["schedules.id"],
name=op.f("fk_executions_schedule_id_schedules"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_executions")),
sa.UniqueConstraint(
"schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence"
),
)
op.create_index("ix_executions_job_id", "executions", ["job_id"], unique=False)
op.create_index(
"ix_executions_state_created", "executions", ["state", "created_at"], unique=False
)
op.create_index(
"uq_executions_active_job",
"executions",
["job_id"],
unique=True,
sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"),
)
op.create_table(
"backups",
sa.Column("execution_id", sa.String(length=36), nullable=False),
sa.Column("parent_backup_id", sa.String(length=36), nullable=True),
sa.Column("manifest_id", sa.String(length=36), nullable=False),
sa.Column("manifest_digest", sa.String(length=64), nullable=False),
sa.Column("logical_bytes", sa.Integer(), nullable=False),
sa.Column("stored_bytes", sa.Integer(), nullable=False),
sa.Column("integrity", sa.String(length=32), nullable=False),
sa.Column("pinned", sa.Boolean(), nullable=False),
sa.Column("tombstoned_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("id", sa.String(length=36), nullable=False),
sa.CheckConstraint(
"integrity IN ('unverified','verified','degraded','corrupt')",
name=op.f("ck_backups_integrity"),
),
sa.CheckConstraint("logical_bytes >= 0", name=op.f("ck_backups_logical_bytes_nonnegative")),
sa.CheckConstraint("stored_bytes >= 0", name=op.f("ck_backups_stored_bytes_nonnegative")),
sa.ForeignKeyConstraint(
["execution_id"],
["executions.id"],
name=op.f("fk_backups_execution_id_executions"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["parent_backup_id"],
["backups.id"],
name=op.f("fk_backups_parent_backup_id_backups"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_backups")),
sa.UniqueConstraint("execution_id", name=op.f("uq_backups_execution_id")),
sa.UniqueConstraint("manifest_digest", name=op.f("uq_backups_manifest_digest")),
sa.UniqueConstraint("manifest_id", name=op.f("uq_backups_manifest_id")),
)
op.create_index("ix_backups_created_at", "backups", ["created_at"], unique=False)
op.create_table(
"restores",
sa.Column("backup_id", sa.String(length=36), nullable=False),
sa.Column("destination", sa.Text(), nullable=False),
sa.Column("selection", sa.JSON(), nullable=False),
sa.Column("overwrite_policy", sa.String(length=32), nullable=False),
sa.Column("state", sa.String(length=32), nullable=False),
sa.Column("result", sa.JSON(), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.CheckConstraint(
"overwrite_policy IN ('fail','skip','replace')",
name=op.f("ck_restores_overwrite_policy"),
),
sa.CheckConstraint(
"state IN ('queued','running','committed','cancelled','failed')",
name=op.f("ck_restores_state"),
),
sa.ForeignKeyConstraint(
["backup_id"],
["backups.id"],
name=op.f("fk_restores_backup_id_backups"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_restores")),
)
op.create_index("ix_restores_backup_id", "restores", ["backup_id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_restores_backup_id", table_name="restores")
op.drop_table("restores")
op.drop_index("ix_backups_created_at", table_name="backups")
op.drop_table("backups")
op.drop_index(
"uq_executions_active_job",
table_name="executions",
sqlite_where=sa.text("state IN ('queued','preparing','running','verifying','cancelling')"),
)
op.drop_index("ix_executions_state_created", table_name="executions")
op.drop_index("ix_executions_job_id", table_name="executions")
op.drop_table("executions")
op.drop_table("schedules")
op.drop_index("ix_notification_deliveries_state_next", table_name="notification_deliveries")
op.drop_table("notification_deliveries")
op.drop_table("notification_subscriptions")
op.drop_index("ix_jobs_source_id", table_name="jobs")
op.drop_index("ix_jobs_repository_id", table_name="jobs")
op.drop_table("jobs")
op.drop_index("ix_idempotency_created_at", table_name="idempotency_records")
op.drop_table("idempotency_records")
op.drop_index("ix_audit_events_created_at", table_name="audit_events")
op.drop_index("ix_audit_events_actor_id", table_name="audit_events")
op.drop_table("audit_events")
op.drop_index("ix_api_tokens_owner_id", table_name="api_tokens")
op.drop_table("api_tokens")
op.drop_table("users")
op.drop_table("sources")
op.drop_table("secrets")
op.drop_table("repositories")
# ### end Alembic commands ###
+50
View File
@@ -0,0 +1,50 @@
"""persist authenticated sessions
Revision ID: 0002_sessions
Revises: 0001_v2_baseline
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_sessions"
down_revision: str | Sequence[str] | None = "0001_v2_baseline"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"sessions",
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
name=op.f("fk_sessions_user_id_users"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_sessions")),
)
op.create_index(op.f("ix_sessions_user_id"), "sessions", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_sessions_user_id"), table_name="sessions")
op.drop_table("sessions")
@@ -0,0 +1,37 @@
"""add durable execution event revision
Revision ID: 0003_execution_events
Revises: 0002_sessions
"""
import sqlalchemy as sa
from alembic import op
revision = "0003_execution_events"
down_revision = "0002_sessions"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("executions") as batch:
batch.add_column(sa.Column("revision", sa.Integer(), nullable=False, server_default="0"))
op.create_table(
"execution_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"execution_id",
sa.String(36),
sa.ForeignKey("executions.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.UniqueConstraint("execution_id", "revision", name="uq_execution_event_revision"),
)
def downgrade() -> None:
op.drop_table("execution_events")
with op.batch_alter_table("executions") as batch:
batch.drop_column("revision")
@@ -0,0 +1,29 @@
"""bind repositories to manifest signing public keys
Revision ID: 0004_repository_signing_keys
Revises: 0003_execution_events
"""
import sqlalchemy as sa
from alembic import op
revision = "0004_repository_signing_keys"
down_revision = "0003_execution_events"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("repositories") as batch:
batch.add_column(
sa.Column("signing_key_id", sa.String(length=64), nullable=False, server_default="")
)
batch.add_column(
sa.Column("signing_public_key", sa.String(length=64), nullable=False, server_default="")
)
def downgrade() -> None:
with op.batch_alter_table("repositories") as batch:
batch.drop_column("signing_public_key")
batch.drop_column("signing_key_id")
@@ -0,0 +1,23 @@
"""persist restore dry-run intent
Revision ID: 0005_restore_dry_run
Revises: 0004_repository_signing_keys
"""
import sqlalchemy as sa
from alembic import op
revision = "0005_restore_dry_run"
down_revision = "0004_repository_signing_keys"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("restores") as batch:
batch.add_column(sa.Column("dry_run", sa.Boolean(), nullable=False, server_default="0"))
def downgrade() -> None:
with op.batch_alter_table("restores") as batch:
batch.drop_column("dry_run")
@@ -0,0 +1,43 @@
"""restrict persisted sources to local
Revision ID: 0006_local_sources_only
Revises: 0005_restore_dry_run
"""
import sqlalchemy as sa
from alembic import op
revision = "0006_local_sources_only"
down_revision = "0005_restore_dry_run"
branch_labels = None
depends_on = None
def _reject_nonlocal_sources() -> None:
connection = op.get_bind()
sources = sa.table("sources", sa.column("kind"))
count = connection.scalar(
sa.select(sa.func.count()).select_from(sources).where(sources.c.kind != "local")
)
if count is None:
raise RuntimeError("Cannot inspect persisted source kinds before migration.")
if count:
raise RuntimeError(
"Cannot restrict sources to local: "
f"found {count} non-local source row(s). Remove or migrate them before upgrading."
)
def upgrade() -> None:
_reject_nonlocal_sources()
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'")
def downgrade() -> None:
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(
op.f("ck_sources_kind"), "kind IN ('local','sftp','postgresql','mysql')"
)
@@ -0,0 +1,87 @@
"""add repository data key epochs
Revision ID: 0007_repository_data_key_epochs
Revises: 0006_local_sources_only
"""
import sqlalchemy as sa
from alembic import op
revision = "0007_repository_data_key_epochs"
down_revision = "0006_local_sources_only"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repositories", sa.Column("active_data_key_id", sa.String(36), nullable=True))
op.add_column("backups", sa.Column("data_key_id", sa.String(36), nullable=True))
op.create_table(
"repository_data_key_epochs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"repository_id",
sa.String(36),
sa.ForeignKey("repositories.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("key_id", sa.String(36), nullable=False),
sa.Column("state", sa.String(16), nullable=False),
sa.Column("retired_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint(
"repository_id", "key_id", name="uq_repository_data_key_epochs_repository_key_epoch"
),
sa.CheckConstraint(
"state IN ('active','retired')",
name="ck_repository_data_key_epochs_repository_data_key_epoch_state",
),
)
op.create_index(
"ix_repository_data_key_epochs_repository_id",
"repository_data_key_epochs",
["repository_id"],
)
op.create_index(
"uq_repository_data_key_epochs_active",
"repository_data_key_epochs",
["repository_id"],
unique=True,
sqlite_where=sa.text("state = 'active'"),
)
def downgrade() -> None:
connection = op.get_bind()
epochs = sa.table("repository_data_key_epochs")
repositories = sa.table("repositories", sa.column("active_data_key_id"))
backups = sa.table("backups", sa.column("data_key_id"))
epoch_count = connection.scalar(sa.select(sa.func.count()).select_from(epochs))
active_key_count = connection.scalar(
sa.select(sa.func.count())
.select_from(repositories)
.where(repositories.c.active_data_key_id.is_not(None))
)
backup_key_count = connection.scalar(
sa.select(sa.func.count()).select_from(backups).where(backups.c.data_key_id.is_not(None))
)
if epoch_count or active_key_count or backup_key_count:
raise RuntimeError("cannot downgrade while repository data key metadata exists")
op.drop_index("uq_repository_data_key_epochs_active", table_name="repository_data_key_epochs")
op.drop_index(
"ix_repository_data_key_epochs_repository_id", table_name="repository_data_key_epochs"
)
op.drop_table("repository_data_key_epochs")
op.drop_column("backups", "data_key_id")
op.drop_column("repositories", "active_data_key_id")
@@ -0,0 +1,445 @@
"""replace notification attempt stub with a durable M12 outbox
Revision ID: 0008_notification_outbox
Revises: 0007_repository_data_key_epochs
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
import sqlalchemy as sa
from alembic import op
from backup_tool.ids import new_uuid7
revision = "0008_notification_outbox"
down_revision = "0007_repository_data_key_epochs"
branch_labels = None
depends_on = None
def _now() -> datetime:
return datetime.now(UTC)
def _parameterized_execute(connection: sa.Connection, statement: object) -> Any:
"""Execute only SQLAlchemy Core statements, never dynamic SQL strings."""
database = connection.execution_options()
return database.execute(statement) # type: ignore[arg-type]
def upgrade() -> None:
connection = op.get_bind()
# SQLite batch-rebuilds notification_subscriptions. Its old delivery table
# references this table, so enforcement must be suspended for this migration
# only while the legacy rows are copied into the replacement outbox shape.
if connection.dialect.name == "sqlite":
connection.exec_driver_sql("PRAGMA foreign_keys=OFF")
# Extend the existing subscription rows first: pre-M12 rows stay disabled until
# an operator explicitly configures a new credential/key.
with op.batch_alter_table("notification_subscriptions") as batch:
batch.add_column(
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60")
)
batch.add_column(sa.Column("rate_tokens", sa.Float(), nullable=False, server_default="60"))
batch.add_column(sa.Column("rate_updated_at", sa.DateTime(timezone=True), nullable=True))
batch.add_column(sa.Column("revision", sa.Integer(), nullable=False, server_default="1"))
batch.create_check_constraint("rate_positive", "rate_limit_per_minute > 0")
batch.create_check_constraint("rate_tokens_nonnegative", "rate_tokens >= 0")
batch.create_check_constraint("revision_positive", "revision > 0")
op.create_table(
"notification_events",
sa.Column("type", sa.String(96), nullable=False),
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("correlation_id", sa.String(36), nullable=False),
sa.Column("severity", sa.String(16), nullable=False),
sa.Column("resource_refs", sa.JSON(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("canonical_envelope", sa.Text(), nullable=False),
sa.Column("deduplication_key", sa.String(255), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.CheckConstraint("schema_version = 1", name="ck_notification_events_schema_version"),
sa.CheckConstraint(
"severity IN ('info','warning','error','security')",
name="ck_notification_events_severity",
),
sa.UniqueConstraint("deduplication_key", name="uq_notification_events_deduplication_key"),
)
op.create_index(
"ix_notification_events_type_occurred", "notification_events", ["type", "occurred_at"]
)
# Preserve unexpected rows from the unused baseline shape. Renaming first
# keeps the original data intact if an upgrade is interrupted before copy.
op.rename_table("notification_deliveries", "notification_deliveries_legacy")
op.create_table(
"notification_deliveries",
sa.Column(
"event_id",
sa.String(36),
sa.ForeignKey("notification_events.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column(
"subscription_id",
sa.String(36),
sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("state", sa.String(32), nullable=False, server_default="pending"),
sa.Column("due_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("lease_owner", sa.String(255), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("terminal_reason", sa.String(96), nullable=True),
sa.Column("response_class", sa.String(64), nullable=True),
sa.Column("response_summary", sa.String(512), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint(
"attempt_count >= 0", name="ck_notification_deliveries_attempt_count_nonnegative"
),
sa.CheckConstraint(
"state IN ('pending','leased','delivered','retry','failed')",
name="ck_notification_deliveries_state",
),
sa.UniqueConstraint(
"event_id", "subscription_id", name="uq_notification_deliveries_event_subscription"
),
)
op.create_index(
"ix_notification_deliveries_due", "notification_deliveries", ["state", "due_at"]
)
op.create_index(
"ix_notification_deliveries_lease", "notification_deliveries", ["state", "lease_expires_at"]
)
op.create_table(
"notification_delivery_attempts",
sa.Column(
"delivery_id",
sa.String(36),
sa.ForeignKey("notification_deliveries.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("outcome", sa.String(32), nullable=False, server_default="started"),
sa.Column("response_class", sa.String(64), nullable=True),
sa.Column("diagnostic", sa.String(512), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.CheckConstraint("number > 0", name="ck_notification_delivery_attempts_number_positive"),
sa.CheckConstraint(
"outcome IN ('started','delivered','retry','failed')",
name="ck_notification_delivery_attempts_outcome",
),
sa.UniqueConstraint(
"delivery_id", "number", name="uq_notification_delivery_attempts_delivery_number"
),
)
op.create_index(
"ix_notification_attempts_delivery",
"notification_delivery_attempts",
["delivery_id", "number"],
)
legacy = sa.table(
"notification_deliveries_legacy",
sa.column("id"),
sa.column("event_id"),
sa.column("subscription_id"),
sa.column("attempt"),
sa.column("state"),
sa.column("response_class"),
sa.column("next_attempt_at"),
sa.column("created_at"),
sa.column("updated_at"),
)
rows = _parameterized_execute(connection, sa.select(legacy)).mappings().all()
events = sa.table(
"notification_events",
sa.column("id"),
sa.column("type"),
sa.column("schema_version"),
sa.column("occurred_at"),
sa.column("correlation_id"),
sa.column("severity"),
sa.column("resource_refs", sa.JSON()),
sa.column("payload", sa.JSON()),
sa.column("canonical_envelope"),
sa.column("deduplication_key"),
)
deliveries = sa.table(
"notification_deliveries",
*[
sa.column(name)
for name in (
"id",
"event_id",
"subscription_id",
"state",
"due_at",
"attempt_count",
"terminal_reason",
"response_class",
"response_summary",
"created_at",
"updated_at",
)
],
)
attempts = sa.table(
"notification_delivery_attempts",
*[
sa.column(name)
for name in (
"id",
"delivery_id",
"number",
"started_at",
"completed_at",
"outcome",
"response_class",
"diagnostic",
)
],
)
for row in rows:
occurred = row["created_at"] or _now()
event_id, delivery_id, attempt_id = (str(new_uuid7()), str(new_uuid7()), str(new_uuid7()))
payload = {"legacy_event_id": str(row["event_id"]), "legacy_delivery_id": str(row["id"])}
envelope = {
"event_schema_version": 1,
"id": event_id,
"type": "notification.legacy",
"occurred_at": occurred.isoformat()
if hasattr(occurred, "isoformat")
else str(occurred),
"correlation_id": event_id,
"severity": "warning",
"resource": {"subscription_id": str(row["subscription_id"])},
"payload": payload,
}
_parameterized_execute(
connection,
events.insert().values(
id=event_id,
type="notification.legacy",
schema_version=1,
occurred_at=occurred,
correlation_id=event_id,
severity="warning",
resource_refs=envelope["resource"],
payload=payload,
canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")),
deduplication_key=f"legacy:{row['id']}",
),
)
old_state = str(row["state"])
new_state = "retry" if old_state == "pending" else old_state
try:
legacy_attempt = max(1, int(row["attempt"]))
except (TypeError, ValueError) as error:
raise RuntimeError("legacy notification delivery has an invalid attempt") from error
_parameterized_execute(
connection,
deliveries.insert().values(
id=delivery_id,
event_id=event_id,
subscription_id=row["subscription_id"],
state=new_state,
due_at=row["next_attempt_at"] or occurred,
attempt_count=legacy_attempt,
terminal_reason="legacy_migrated" if new_state == "failed" else None,
response_class=row["response_class"],
response_summary="legacy delivery migrated",
created_at=occurred,
updated_at=row["updated_at"] or occurred,
),
)
_parameterized_execute(
connection,
attempts.insert().values(
id=attempt_id,
delivery_id=delivery_id,
number=legacy_attempt,
started_at=occurred,
completed_at=row["updated_at"] if new_state in {"delivered", "failed"} else None,
outcome="delivered"
if new_state == "delivered"
else ("failed" if new_state == "failed" else "retry"),
response_class=row["response_class"],
diagnostic="legacy delivery migrated",
),
)
op.drop_table("notification_deliveries_legacy")
op.create_table(
"notification_signing_keys",
sa.Column(
"subscription_id",
sa.String(36),
sa.ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("version", sa.Integer(), nullable=False),
sa.Column(
"secret_id",
sa.String(36),
sa.ForeignKey("secrets.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("state", sa.String(16), nullable=False, server_default="active"),
sa.Column("overlap_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint("version > 0", name="ck_notification_signing_keys_version_positive"),
sa.CheckConstraint(
"state IN ('active','overlap','retired')", name="ck_notification_signing_keys_state"
),
sa.UniqueConstraint(
"subscription_id", "version", name="uq_notification_signing_keys_subscription_version"
),
)
op.create_index(
"ix_notification_signing_keys_subscription",
"notification_signing_keys",
["subscription_id", "state"],
)
op.create_index(
"uq_notification_signing_keys_active",
"notification_signing_keys",
["subscription_id"],
unique=True,
sqlite_where=sa.text("state = 'active'"),
)
op.create_index(
"uq_notification_signing_keys_overlap",
"notification_signing_keys",
["subscription_id"],
unique=True,
sqlite_where=sa.text("state = 'overlap'"),
)
op.create_table(
"notification_email_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("host", sa.String(255), nullable=False),
sa.Column("port", sa.Integer(), nullable=False, server_default="587"),
sa.Column("username", sa.String(255), nullable=False),
sa.Column(
"password_secret_id",
sa.String(36),
sa.ForeignKey("secrets.id", ondelete="RESTRICT"),
nullable=False,
),
sa.Column("sender", sa.String(320), nullable=False),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="5"),
sa.Column("rate_limit_per_minute", sa.Integer(), nullable=False, server_default="60"),
sa.CheckConstraint("id = 1", name="ck_notification_email_settings_singleton"),
sa.CheckConstraint("port > 0 AND port < 65536", name="ck_notification_email_settings_port"),
sa.CheckConstraint("max_attempts > 0", name="ck_notification_email_settings_max_attempts"),
sa.CheckConstraint(
"rate_limit_per_minute > 0", name="ck_notification_email_settings_rate_positive"
),
)
if connection.dialect.name == "sqlite":
connection.exec_driver_sql("PRAGMA foreign_keys=ON")
def downgrade() -> None:
bind = op.get_bind()
for table in (
"notification_events",
"notification_delivery_attempts",
"notification_signing_keys",
"notification_email_settings",
):
if bind.scalar(sa.text(f"SELECT count(*) FROM {table}")):
raise RuntimeError(
"cannot downgrade while M12 notification history or configuration exists"
)
# A fresh M12 schema can safely return to the historical stub shape.
op.drop_table("notification_email_settings")
op.drop_index("uq_notification_signing_keys_overlap", table_name="notification_signing_keys")
op.drop_index("uq_notification_signing_keys_active", table_name="notification_signing_keys")
op.drop_index(
"ix_notification_signing_keys_subscription", table_name="notification_signing_keys"
)
op.drop_table("notification_signing_keys")
op.drop_index("ix_notification_attempts_delivery", table_name="notification_delivery_attempts")
op.drop_table("notification_delivery_attempts")
op.drop_index("ix_notification_deliveries_lease", table_name="notification_deliveries")
op.drop_index("ix_notification_deliveries_due", table_name="notification_deliveries")
op.drop_table("notification_deliveries")
op.drop_index("ix_notification_events_type_occurred", table_name="notification_events")
op.drop_table("notification_events")
op.create_table(
"notification_deliveries",
sa.Column("event_id", sa.String(36), nullable=False),
sa.Column("subscription_id", sa.String(36), nullable=False),
sa.Column("attempt", sa.Integer(), nullable=False),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("response_class", sa.String(64)),
sa.Column("next_attempt_at", sa.DateTime(timezone=True)),
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.CheckConstraint("attempt > 0", name="ck_notification_deliveries_attempt_positive"),
sa.CheckConstraint(
"state IN ('pending','delivered','retry','failed')",
name="ck_notification_deliveries_state",
),
sa.ForeignKeyConstraint(
["subscription_id"], ["notification_subscriptions.id"], ondelete="RESTRICT"
),
sa.UniqueConstraint("event_id", "subscription_id", "attempt", name="uq_delivery_attempt"),
)
op.create_index(
"ix_notification_deliveries_state_next",
"notification_deliveries",
["state", "next_attempt_at"],
)
with op.batch_alter_table("notification_subscriptions") as batch:
batch.drop_constraint("revision_positive", type_="check")
batch.drop_constraint("rate_tokens_nonnegative", type_="check")
batch.drop_constraint("rate_positive", type_="check")
batch.drop_column("revision")
batch.drop_column("rate_updated_at")
batch.drop_column("rate_tokens")
batch.drop_column("rate_limit_per_minute")
@@ -0,0 +1,43 @@
"""allow staged SSH source definitions
Revision ID: 0009_allow_ssh_sources
Revises: 0008_notification_outbox
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0009_allow_ssh_sources"
down_revision = "0008_notification_outbox"
branch_labels = None
depends_on = None
def _reject_ssh_sources() -> None:
connection = op.get_bind()
sources = sa.table("sources", sa.column("kind"))
count = connection.scalar(
sa.select(sa.func.count()).select_from(sources).where(sources.c.kind == "ssh")
)
if count is None:
raise RuntimeError("Cannot inspect persisted SSH sources before migration.")
if count:
raise RuntimeError(
"Cannot restrict sources to local: "
f"found {count} SSH source row(s). Remove them before downgrading."
)
def upgrade() -> None:
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind IN ('local','ssh')")
def downgrade() -> None:
_reject_ssh_sources()
with op.batch_alter_table("sources") as batch:
batch.drop_constraint(op.f("ck_sources_kind"), type_="check")
batch.create_check_constraint(op.f("ck_sources_kind"), "kind = 'local'")
+65
View File
@@ -0,0 +1,65 @@
[build-system]
requires = ["setuptools==80.9.0", "wheel==0.46.3"]
build-backend = "setuptools.build_meta"
[project]
name = "backup-tool"
version = "2.0.0.dev0"
description = "Self-hosted, integrity-first backup appliance"
license = "MIT"
requires-python = ">=3.12,<3.15"
authors = [{ name = "Backup Tool Team" }]
dependencies = [
"aiofiles==25.1.0",
"aiosqlite==0.22.1",
"alembic==1.18.5",
"argon2-cffi==25.1.0",
"apscheduler==3.11.3",
"cryptography==49.0.0",
"fastapi==0.136.1",
"httpx==0.28.1",
"pydantic==2.13.4",
"pydantic-settings==2.14.2",
"paramiko==5.0.0",
"sqlalchemy[asyncio]==2.0.49",
"uvicorn[standard]==0.51.0",
]
[project.optional-dependencies]
dev = [
"jsonschema==4.26.0",
"mypy==2.3.0",
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"ruff==0.16.0",
]
prod = ["gunicorn==23.0.0"]
[project.scripts]
backup-tool = "backup_tool.cli:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
include = ["backup_tool*"]
[tool.setuptools.package-data]
backup_tool = ["py.typed"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["backup_tool"]
mypy_path = "src"
+3
View File
@@ -0,0 +1,3 @@
"""Backup Tool v2 package."""
__version__ = "2.0.0.dev0"
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import os
import stat
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from backup_tool.config import Settings
class SourceError(ValueError):
"""A redacted source failure with a stable execution reason."""
def __init__(self, message: str, *, reason_code: str = "source_invalid") -> None:
super().__init__(message)
self.reason_code = reason_code
@dataclass(frozen=True)
class Entry:
path: str
kind: str
size: int
mode: int
mtime_ns: int
link_target: str | None = None
class SourceReader(Protocol):
def validate_config(self) -> None: ...
async def probe(self) -> dict[str, int]: ...
def enumerate_entries(self) -> AsyncIterator[Entry]: ...
def open_content(self, path: str) -> AsyncIterator[bytes]: ...
async def close(self) -> None: ...
class LocalAdapter:
def __init__(self, root: Path, settings: Settings):
self.root = root.expanduser().resolve()
self.settings = settings
def validate_config(self) -> None:
if not self.root.is_dir():
raise SourceError("local source root must be an existing directory")
if not any(self.root.is_relative_to(root) for root in self.settings.local_source_roots):
raise SourceError("local source root is outside configured allowlists")
async def probe(self) -> dict[str, int]:
self.validate_config()
count = sum(1 for item in self.root.rglob("*") if item.is_file() and not item.is_symlink())
return {"entry_count": count}
async def enumerate_entries(self) -> AsyncIterator[Entry]:
self.validate_config()
for item in sorted(self.root.rglob("*"), key=lambda candidate: candidate.as_posix()):
relative = item.relative_to(self.root).as_posix()
metadata = item.lstat()
if stat.S_ISLNK(metadata.st_mode):
target = os.readlink(item)
target_path = Path(target)
if (
target_path.is_absolute()
or "\\" in target
or ".." in target_path.parts
or not target
):
raise SourceError(f"symlink target for {relative!r} is unsafe")
yield Entry(
relative,
"symlink",
0,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
target,
)
elif stat.S_ISREG(metadata.st_mode):
yield Entry(
relative,
"file",
metadata.st_size,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
)
elif stat.S_ISDIR(metadata.st_mode):
yield Entry(
relative,
"directory",
0,
stat.S_IMODE(metadata.st_mode),
metadata.st_mtime_ns,
)
else:
raise SourceError(f"local source entry {relative!r} has an unsupported type")
async def close(self) -> None:
return None
async def open_content(self, path: str) -> AsyncIterator[bytes]:
requested = Path(path)
if not path or requested.is_absolute() or "\\" in path or ".." in requested.parts:
raise SourceError("invalid local source entry")
candidate = self.root / requested
metadata = candidate.lstat()
if not stat.S_ISREG(metadata.st_mode) or candidate.is_symlink():
raise SourceError("invalid local source entry")
resolved = candidate.resolve()
if not resolved.is_relative_to(self.root):
raise SourceError("invalid local source entry")
with candidate.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
yield chunk
+1
View File
@@ -0,0 +1 @@
"""HTTP boundary for Backup Tool v2."""
File diff suppressed because it is too large Load Diff
+953
View File
@@ -0,0 +1,953 @@
"""Backup Tool v2 process-role command line."""
from __future__ import annotations
import argparse
import asyncio
import base64
import binascii
import json
import os
import socket
from collections.abc import Callable, Sequence
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from alembic import command
from alembic.config import Config
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backup_tool import __version__
from backup_tool.config import Settings
from .db.engine import assert_schema_current, create_engine
from .db.models import (
Backup,
Base,
Execution,
Job,
Repository,
RepositoryDataKeyEpoch,
Source,
)
from .repository import (
begin_key_rotation,
finish_key_rotation,
inspect_repository,
install_signing_key,
load_signing_key,
reconcile_key_rotations,
replace_active_data_key,
)
from .scheduler import run_scheduler
from .security.recovery_bundle import (
RecoveryBundleError,
decrypt_bundle,
encrypt_bundle,
read_bundle_file,
read_passphrase_fd,
write_bundle_exclusive,
)
from .security.repository_crypto import (
RepositoryKeyError,
create_data_key,
install_data_key,
load_data_key,
)
from .web import run_web
from .worker import run_worker
RoleHandler = Callable[[Settings], int]
DATABASE_ROLES = ("web", "scheduler", "worker", "admin")
def _placeholder_role(_settings: Settings) -> int:
return 0
ROLE_HANDLERS: dict[str, RoleHandler] = {
"web": run_web,
"scheduler": run_scheduler,
"worker": run_worker,
"admin": _placeholder_role,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="backup-tool")
parser.add_argument("--version", action="version", version=__version__)
subparsers = parser.add_subparsers(dest="role", required=True)
for role in DATABASE_ROLES:
role_parser = subparsers.add_parser(role, help=f"run the {role} role")
if role == "admin":
admin_parsers = role_parser.add_subparsers(dest="admin_command")
repository_key = admin_parsers.add_parser("repository-key")
repository_key_parsers = repository_key.add_subparsers(dest="repository_key_command")
rotate = repository_key_parsers.add_parser("rotate")
rotate.add_argument("--repository-id", required=True)
recovery = admin_parsers.add_parser("recovery")
recovery_parsers = recovery.add_subparsers(dest="recovery_command", required=True)
export = recovery_parsers.add_parser("export")
export.add_argument("--output", required=True, type=Path)
export.add_argument("--passphrase-fd", required=True, type=int)
validate = recovery_parsers.add_parser("validate")
validate.add_argument("--input", required=True, type=Path)
validate.add_argument("--passphrase-fd", required=True, type=int)
import_ = recovery_parsers.add_parser("import")
import_.add_argument("--input", required=True, type=Path)
import_.add_argument("--passphrase-fd", required=True, type=int)
migrate = subparsers.add_parser("migrate", help="manage the metadata schema")
migrate.add_argument("action", choices=("upgrade", "downgrade", "current"))
health = subparsers.add_parser("health", help="check runtime role readiness")
health.add_argument("health_role", choices=("web", "scheduler", "worker"))
return parser
def build_alembic_config(settings: Settings) -> Config:
default_root = Path(__file__).resolve().parents[2]
backend_root = Path(os.environ.get("BACKUP_TOOL_ALEMBIC_ROOT", default_root))
migration = Config(backend_root / "alembic.ini")
migration.set_main_option("sqlalchemy.url", settings.database_url)
return migration
def require_current_schema(settings: Settings) -> None:
async def check() -> None:
engine = create_engine(settings)
try:
await assert_schema_current(engine, build_alembic_config(settings))
finally:
await engine.dispose()
asyncio.run(check())
def run_role_healthcheck(settings: Settings, role: str) -> int:
"""Verify the selected isolated runtime role is ready to serve work."""
if role != "web":
from backup_tool.observability.health import check_role_readiness
asyncio.run(check_role_readiness(settings, role))
return 0
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(2)
client.connect(str(settings.web_socket_path))
request = b"GET /readyz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
client.sendall(request)
status_line = client.recv(64).split(b"\r\n", 1)[0]
if status_line != b"HTTP/1.1 200 OK":
raise RuntimeError("web role is not ready")
return 0
def run_migration(action: str, settings: Settings) -> int:
migration = build_alembic_config(settings)
if action == "upgrade":
command.upgrade(migration, "head")
elif action == "downgrade":
command.downgrade(migration, "base")
else:
command.current(migration)
return 0
def load_settings() -> Settings:
return Settings() # type: ignore[call-arg]
async def rotate_repository_key(
settings: Settings,
repository_id: str,
*,
after_database_commit: Callable[[], None] | None = None,
) -> dict[str, str]:
"""Journal a key rotation so a crash always has a deterministic recovery path."""
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
new_key_path: Path | None = None
database_committed = False
journal_started = False
root: Path | None = None
old_key_id = ""
new_key_id = ""
try:
async with sessions() as db:
await reconcile_key_rotations(settings, db)
await db.rollback()
async with db.begin():
repository = await db.scalar(
select(Repository).where(Repository.id == repository_id).with_for_update()
)
if repository is None:
raise ValueError("repository was not found")
if repository.encryption != "aes-256-gcm" or repository.active_data_key_id is None:
raise ValueError("repository encryption is not enabled")
inspected = inspect_repository(settings, Path(repository.root))
root = inspected.root
if (
inspected.encryption != "aes-256-gcm"
or inspected.data_key_id != repository.active_data_key_id
):
raise ValueError("repository encryption metadata is invalid")
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == repository.id
)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
if len(active) != 1 or active[0].key_id != repository.active_data_key_id:
raise ValueError("repository key epochs are invalid")
old_key_id = active[0].key_id
new_key_id, new_key_path = create_data_key(settings, inspected.repository_id)
begin_key_rotation(
inspected.root,
repository.id,
inspected.repository_id,
old_key_id,
new_key_id,
)
journal_started = True
active[0].state = "retired"
active[0].retired_at = datetime.now(UTC)
db.add(
RepositoryDataKeyEpoch(
repository_id=repository.id,
key_id=new_key_id,
state="active",
)
)
repository.active_data_key_id = new_key_id
database_committed = True
if after_database_commit is not None:
after_database_commit()
if root is None:
raise ValueError("repository rotation metadata is invalid")
replace_active_data_key(root, old_key_id, new_key_id)
finish_key_rotation(root)
journal_started = False
return {
"repository_id": repository_id,
"retired_key_id": old_key_id,
"active_key_id": new_key_id,
}
finally:
if not database_committed:
if journal_started and root is not None:
with suppress(ValueError):
finish_key_rotation(root)
if new_key_path is not None:
new_key_path.unlink(missing_ok=True)
await engine.dispose()
async def recovery_export_payload(settings: Settings) -> dict[str, Any]:
"""Collect the current repository trust catalog and offline key material."""
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
try:
async with sessions() as db:
await reconcile_key_rotations(settings, db)
await db.rollback()
repositories = list(
(await db.scalars(select(Repository).order_by(Repository.id))).all()
)
catalog_repositories: list[dict[str, Any]] = []
key_records: list[dict[str, Any]] = []
for repository in repositories:
inspected = inspect_repository(settings, Path(repository.root))
if inspected.encryption != repository.encryption or (
repository.encryption == "aes-256-gcm"
and repository.active_data_key_id != inspected.data_key_id
):
raise RecoveryBundleError("recovery bundle export is unavailable")
private_key = load_signing_key(
settings,
inspected.repository_id,
repository.signing_key_id,
repository.signing_public_key,
)
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch)
.where(RepositoryDataKeyEpoch.repository_id == repository.id)
.order_by(RepositoryDataKeyEpoch.key_id)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
if repository.encryption == "aes-256-gcm":
if (
repository.active_data_key_id is None
or len(active) != 1
or active[0].key_id != repository.active_data_key_id
):
raise RecoveryBundleError("recovery bundle export is unavailable")
elif epochs:
raise RecoveryBundleError("recovery bundle export is unavailable")
data_keys: list[dict[str, str]] = []
for epoch in epochs:
data_keys.append(
{
"key_id": epoch.key_id,
"key": base64.b64encode(
load_data_key(settings, inspected.repository_id, epoch.key_id)
).decode("ascii"),
}
)
catalog_repositories.append(
{
"active_data_key_id": repository.active_data_key_id,
"compression": repository.compression,
"data_key_epochs": [
{
"key_id": epoch.key_id,
"retired_at": (
epoch.retired_at.isoformat()
if epoch.retired_at is not None
else None
),
"state": epoch.state,
}
for epoch in epochs
],
"database_id": repository.id,
"encryption": repository.encryption,
"format_version": repository.format_version,
"name": repository.name,
"repository_id": inspected.repository_id,
"root": repository.root,
"signing_key_id": repository.signing_key_id,
"signing_public_key": repository.signing_public_key,
"state": repository.state,
}
)
key_records.append(
{
"data_keys": data_keys,
"repository_id": inspected.repository_id,
"signing_private_key": base64.b64encode(
private_key.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
).decode("ascii"),
}
)
sources = list((await db.scalars(select(Source).order_by(Source.id))).all())
jobs = list((await db.scalars(select(Job).order_by(Job.id))).all())
executions = list((await db.scalars(select(Execution).order_by(Execution.id))).all())
backups = list((await db.scalars(select(Backup).order_by(Backup.id))).all())
if any(source.secret_refs for source in sources):
raise RecoveryBundleError("recovery bundle export is unavailable")
return {
"catalog": {
"backups": [
{
"created_at": backup.created_at.isoformat(),
"data_key_id": backup.data_key_id,
"execution_id": backup.execution_id,
"id": backup.id,
"integrity": backup.integrity,
"logical_bytes": backup.logical_bytes,
"manifest_digest": backup.manifest_digest,
"manifest_id": backup.manifest_id,
"parent_backup_id": backup.parent_backup_id,
"pinned": backup.pinned,
"stored_bytes": backup.stored_bytes,
"tombstoned_at": (
backup.tombstoned_at.isoformat()
if backup.tombstoned_at is not None
else None
),
}
for backup in backups
],
"executions": [
{
"attempt": execution.attempt,
"id": execution.id,
"job_id": execution.job_id,
"progress": execution.progress,
"revision": execution.revision,
"trigger": execution.trigger,
}
for execution in executions
],
"format": "backup-tool-recovery-catalog",
"jobs": [
{
"allow_empty": job.allow_empty,
"exclusions": job.exclusions,
"id": job.id,
"name": job.name,
"repository_id": job.repository_id,
"requested_mode": job.requested_mode,
"retention": job.retention,
"source_id": job.source_id,
}
for job in jobs
],
"repositories": catalog_repositories,
"sources": [
{
"id": source.id,
"kind": source.kind,
"name": source.name,
"public_config": source.public_config,
}
for source in sources
],
"version": 2,
},
"keys": key_records,
}
except (OSError, RepositoryKeyError, ValueError) as error:
if isinstance(error, RecoveryBundleError):
raise
raise RecoveryBundleError("recovery bundle export is unavailable") from error
finally:
await engine.dispose()
def _validate_legacy_recovery_payload(payload: dict[str, Any]) -> int:
"""Validate the authenticated export shape without exposing key material."""
try:
if set(payload) != {"catalog", "keys"}:
raise ValueError
catalog = payload["catalog"]
keys = payload["keys"]
if (
not isinstance(catalog, dict)
or set(catalog) != {"format", "repositories", "version"}
or catalog["format"] != "backup-tool-recovery-catalog"
or catalog["version"] != 1
or not isinstance(catalog["repositories"], list)
or not isinstance(keys, list)
or len(catalog["repositories"]) != len(keys)
):
raise ValueError
seen: set[str] = set()
for repository, key_record in zip(catalog["repositories"], keys, strict=True):
if not isinstance(repository, dict) or not isinstance(key_record, dict):
raise ValueError
required_repository = {
"active_data_key_id",
"compression",
"data_key_epochs",
"database_id",
"encryption",
"format_version",
"name",
"repository_id",
"root",
"signing_key_id",
"signing_public_key",
"state",
}
if set(repository) != required_repository or set(key_record) != {
"data_keys",
"repository_id",
"signing_private_key",
}:
raise ValueError
repository_id = repository["repository_id"]
if (
not isinstance(repository_id, str)
or not repository_id
or repository_id in seen
or key_record["repository_id"] != repository_id
or not isinstance(repository["data_key_epochs"], list)
or not isinstance(key_record["data_keys"], list)
):
raise ValueError
seen.add(repository_id)
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
public_key = bytes.fromhex(repository["signing_public_key"])
if len(private_key) != 32 or len(public_key) != 32:
raise ValueError
signing_key = Ed25519PrivateKey.from_private_bytes(private_key)
derived_public = signing_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
if derived_public != public_key:
raise ValueError
epochs = repository["data_key_epochs"]
data_keys = key_record["data_keys"]
if len(epochs) != len(data_keys):
raise ValueError
epoch_ids: set[str] = set()
for epoch, data_key in zip(epochs, data_keys, strict=True):
if (
not isinstance(epoch, dict)
or set(epoch) != {"key_id", "retired_at", "state"}
or not isinstance(data_key, dict)
or set(data_key) != {"key", "key_id"}
or not isinstance(epoch["key_id"], str)
or epoch["key_id"] in epoch_ids
or data_key["key_id"] != epoch["key_id"]
or len(base64.b64decode(data_key["key"], validate=True)) != 32
):
raise ValueError
epoch_ids.add(epoch["key_id"])
active = [epoch for epoch in epochs if epoch["state"] == "active"]
if repository["encryption"] == "aes-256-gcm":
if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]:
raise ValueError
elif (
repository["encryption"] != "none"
or epochs
or repository["active_data_key_id"] is not None
):
raise ValueError
return len(seen)
except (binascii.Error, KeyError, TypeError, ValueError) as error:
raise RecoveryBundleError("recovery bundle is invalid") from error
def _validated_recovery_catalog(
payload: dict[str, Any],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Validate a v2 catalog before any filesystem or metadata mutation."""
try:
if set(payload) != {"catalog", "keys"}:
raise ValueError
catalog = payload["catalog"]
keys = payload["keys"]
required_catalog = {
"backups",
"executions",
"format",
"jobs",
"repositories",
"sources",
"version",
}
if (
not isinstance(catalog, dict)
or set(catalog) != required_catalog
or catalog["format"] != "backup-tool-recovery-catalog"
or catalog["version"] != 2
or not isinstance(keys, list)
or any(
not isinstance(catalog[name], list)
for name in required_catalog - {"format", "version"}
)
or len(catalog["repositories"]) != len(keys)
):
raise ValueError
repositories = catalog["repositories"]
key_by_repository: dict[str, dict[str, Any]] = {}
repository_by_id: dict[str, dict[str, Any]] = {}
canonical_ids: set[str] = set()
for repository, key_record in zip(repositories, keys, strict=True):
if not isinstance(repository, dict) or not isinstance(key_record, dict):
raise ValueError
required_repository = {
"active_data_key_id",
"compression",
"data_key_epochs",
"database_id",
"encryption",
"format_version",
"name",
"repository_id",
"root",
"signing_key_id",
"signing_public_key",
"state",
}
if set(repository) != required_repository or set(key_record) != {
"data_keys",
"repository_id",
"signing_private_key",
}:
raise ValueError
database_id = repository["database_id"]
canonical_id = repository["repository_id"]
if (
not isinstance(database_id, str)
or not isinstance(canonical_id, str)
or not database_id
or not canonical_id
or database_id in repository_by_id
or canonical_id in canonical_ids
or key_record["repository_id"] != canonical_id
or not isinstance(repository["data_key_epochs"], list)
or not isinstance(key_record["data_keys"], list)
):
raise ValueError
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
public_key = bytes.fromhex(repository["signing_public_key"])
signing_key = Ed25519PrivateKey.from_private_bytes(private_key)
if (
len(public_key) != 32
or signing_key.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
!= public_key
):
raise ValueError
epochs = repository["data_key_epochs"]
data_keys = key_record["data_keys"]
if len(epochs) != len(data_keys):
raise ValueError
epoch_ids: set[str] = set()
for epoch, data_key in zip(epochs, data_keys, strict=True):
if (
not isinstance(epoch, dict)
or set(epoch) != {"key_id", "retired_at", "state"}
or epoch.get("state") not in {"active", "retired"}
or not isinstance(epoch.get("key_id"), str)
or epoch["key_id"] in epoch_ids
or not isinstance(data_key, dict)
or set(data_key) != {"key", "key_id"}
or data_key["key_id"] != epoch["key_id"]
or len(base64.b64decode(data_key["key"], validate=True)) != 32
):
raise ValueError
epoch_ids.add(epoch["key_id"])
active = [epoch for epoch in epochs if epoch["state"] == "active"]
if repository["encryption"] == "aes-256-gcm":
if len(active) != 1 or active[0]["key_id"] != repository["active_data_key_id"]:
raise ValueError
elif (
repository["encryption"] != "none"
or epochs
or repository["active_data_key_id"] is not None
):
raise ValueError
repository_by_id[database_id] = repository
canonical_ids.add(canonical_id)
key_by_repository[canonical_id] = key_record
source_ids = _catalog_ids(catalog["sources"], {"id", "kind", "name", "public_config"})
job_ids = _catalog_ids(
catalog["jobs"],
{
"allow_empty",
"exclusions",
"id",
"name",
"repository_id",
"requested_mode",
"retention",
"source_id",
},
)
for job in catalog["jobs"]:
if job["source_id"] not in source_ids or job["repository_id"] not in repository_by_id:
raise ValueError
execution_ids = _catalog_ids(
catalog["executions"], {"attempt", "id", "job_id", "progress", "revision", "trigger"}
)
for execution in catalog["executions"]:
if execution["job_id"] not in job_ids:
raise ValueError
backup_ids = _catalog_ids(
catalog["backups"],
{
"created_at",
"data_key_id",
"execution_id",
"id",
"integrity",
"logical_bytes",
"manifest_digest",
"manifest_id",
"parent_backup_id",
"pinned",
"stored_bytes",
"tombstoned_at",
},
)
for backup in catalog["backups"]:
if backup["execution_id"] not in execution_ids:
raise ValueError
_catalog_datetime(backup["created_at"])
_catalog_datetime(backup["tombstoned_at"], allow_none=True)
if (
backup["parent_backup_id"] is not None
and backup["parent_backup_id"] not in backup_ids
):
raise ValueError
return (
catalog,
[key_by_repository[repository["repository_id"]] for repository in repositories],
)
except (binascii.Error, KeyError, TypeError, ValueError) as error:
raise RecoveryBundleError("recovery bundle is invalid") from error
def _catalog_ids(rows: list[Any], required: set[str]) -> set[str]:
identifiers: set[str] = set()
for row in rows:
if not isinstance(row, dict) or set(row) != required or not isinstance(row.get("id"), str):
raise ValueError
if not row["id"] or row["id"] in identifiers:
raise ValueError
identifiers.add(row["id"])
return identifiers
def _catalog_datetime(value: object, *, allow_none: bool = False) -> datetime | None:
if value is None and allow_none:
return None
if not isinstance(value, str):
raise ValueError
parsed = datetime.fromisoformat(value.replace("Z", "+00"))
if parsed.tzinfo is None:
raise ValueError
return parsed.astimezone(UTC)
def _validate_recovery_payload(payload: dict[str, Any]) -> int:
catalog = payload.get("catalog")
if isinstance(catalog, dict) and catalog.get("version") == 1:
return _validate_legacy_recovery_payload(payload)
validated_catalog, _keys = _validated_recovery_catalog(payload)
return len(validated_catalog["repositories"])
def _install_or_verify_recovery_keys(
settings: Settings,
repository: dict[str, Any],
key_record: dict[str, Any],
installed_paths: list[Path],
) -> None:
inspected = inspect_repository(settings, Path(repository["root"]))
if (
inspected.repository_id != repository["repository_id"]
or inspected.encryption != repository["encryption"]
or inspected.data_key_id != repository["active_data_key_id"]
):
raise RecoveryBundleError("recovery bundle is invalid")
private_key = base64.b64decode(key_record["signing_private_key"], validate=True)
signing_path = settings.data_dir / "repository-keys" / f"{inspected.repository_id}.ed25519"
if signing_path.exists() or signing_path.is_symlink():
loaded = load_signing_key(
settings,
inspected.repository_id,
repository["signing_key_id"],
repository["signing_public_key"],
)
if (
loaded.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
!= private_key
):
raise RecoveryBundleError("recovery destination has conflicting keys")
else:
installed_paths.append(
install_signing_key(
settings,
inspected.repository_id,
repository["signing_key_id"],
repository["signing_public_key"],
private_key,
)
)
for data_key in key_record["data_keys"]:
key_value = base64.b64decode(data_key["key"], validate=True)
key_path = (
settings.data_dir
/ "repository-data-keys"
/ f"{inspected.repository_id}.{data_key['key_id']}.key"
)
if key_path.exists() or key_path.is_symlink():
if load_data_key(settings, inspected.repository_id, data_key["key_id"]) != key_value:
raise RecoveryBundleError("recovery destination has conflicting keys")
else:
installed_paths.append(
install_data_key(settings, inspected.repository_id, data_key["key_id"], key_value)
)
async def import_recovery_payload(
settings: Settings,
payload: dict[str, Any],
*,
after_key_install: Callable[[], None] | None = None,
) -> int:
"""Install a complete authenticated catalog into an empty, migrated database."""
catalog, key_records = _validated_recovery_catalog(payload)
engine = create_engine(settings)
sessions = async_sessionmaker(engine, expire_on_commit=False)
installed_paths: list[Path] = []
completed = False
try:
async with sessions() as db:
for table in Base.metadata.sorted_tables:
if await db.scalar(select(func.count()).select_from(table)):
raise RecoveryBundleError("recovery destination is not empty")
await db.rollback()
for repository, key_record in zip(catalog["repositories"], key_records, strict=True):
_install_or_verify_recovery_keys(settings, repository, key_record, installed_paths)
if after_key_install is not None:
after_key_install()
async with db.begin():
for repository in catalog["repositories"]:
db.add(
Repository(
id=repository["database_id"],
name=repository["name"],
root=repository["root"],
format_version=repository["format_version"],
compression=repository["compression"],
encryption=repository["encryption"],
signing_key_id=repository["signing_key_id"],
signing_public_key=repository["signing_public_key"],
active_data_key_id=repository["active_data_key_id"],
state=repository["state"],
)
)
for repository in catalog["repositories"]:
for epoch in repository["data_key_epochs"]:
db.add(
RepositoryDataKeyEpoch(
repository_id=repository["database_id"],
key_id=epoch["key_id"],
state=epoch["state"],
)
)
await db.flush()
for source in catalog["sources"]:
db.add(
Source(
id=source["id"],
name=source["name"],
kind=source["kind"],
public_config=source["public_config"],
secret_refs=[],
state="unavailable",
)
)
await db.flush()
for job in catalog["jobs"]:
db.add(
Job(
id=job["id"],
name=job["name"],
source_id=job["source_id"],
repository_id=job["repository_id"],
requested_mode=job["requested_mode"],
exclusions=job["exclusions"],
retention=job["retention"],
enabled=False,
allow_empty=job["allow_empty"],
state="archived",
)
)
await db.flush()
for execution in catalog["executions"]:
db.add(
Execution(
id=execution["id"],
job_id=execution["job_id"],
schedule_id=None,
trigger=execution["trigger"],
state="committed",
attempt=execution["attempt"],
progress=execution["progress"],
revision=execution["revision"],
)
)
await db.flush()
for backup in catalog["backups"]:
db.add(
Backup(
id=backup["id"],
execution_id=backup["execution_id"],
parent_backup_id=None,
manifest_id=backup["manifest_id"],
manifest_digest=backup["manifest_digest"],
logical_bytes=backup["logical_bytes"],
stored_bytes=backup["stored_bytes"],
integrity=backup["integrity"],
data_key_id=backup["data_key_id"],
pinned=backup["pinned"],
created_at=_catalog_datetime(backup["created_at"]),
tombstoned_at=_catalog_datetime(
backup["tombstoned_at"], allow_none=True
),
)
)
await db.flush()
for backup in catalog["backups"]:
if backup["parent_backup_id"] is not None:
imported = await db.get(Backup, backup["id"])
if imported is None:
raise RecoveryBundleError("recovery bundle is invalid")
imported.parent_backup_id = backup["parent_backup_id"]
completed = True
return len(catalog["repositories"])
except (OSError, RepositoryKeyError, ValueError) as error:
if isinstance(error, RecoveryBundleError):
raise
raise RecoveryBundleError("recovery import failed") from error
finally:
await engine.dispose()
if not completed:
for path in installed_paths:
path.unlink(missing_ok=True)
def run_admin(settings: Settings, args: argparse.Namespace) -> int:
if (
getattr(args, "admin_command", None) == "repository-key"
and getattr(args, "repository_key_command", None) == "rotate"
):
result = asyncio.run(rotate_repository_key(settings, args.repository_id))
print(json.dumps(result, sort_keys=True))
return 0
if getattr(args, "admin_command", None) == "recovery":
passphrase = read_passphrase_fd(args.passphrase_fd)
if args.recovery_command == "export":
payload = asyncio.run(recovery_export_payload(settings))
write_bundle_exclusive(args.output, encrypt_bundle(payload, passphrase))
print(json.dumps({"repositories": len(payload["keys"]), "status": "exported"}))
return 0
if args.recovery_command == "validate":
payload = decrypt_bundle(read_bundle_file(args.input), passphrase)
validation_result: dict[str, object] = {
"repositories": _validate_recovery_payload(payload),
"status": "valid",
}
print(json.dumps(validation_result))
return 0
if args.recovery_command == "import":
payload = decrypt_bundle(read_bundle_file(args.input), passphrase)
imported = asyncio.run(import_recovery_payload(settings, payload))
print(json.dumps({"repositories": imported, "status": "imported"}))
return 0
raise ValueError("admin command is invalid")
def main(argv: Sequence[str] | None = None, *, settings: Settings | None = None) -> int:
args = build_parser().parse_args(argv)
runtime_settings = settings or load_settings()
if args.role == "migrate":
return run_migration(args.action, runtime_settings)
if args.role == "health":
return run_role_healthcheck(runtime_settings, args.health_role)
require_current_schema(runtime_settings)
if args.role == "admin":
return run_admin(runtime_settings, args)
return ROLE_HANDLERS[args.role](runtime_settings)
if __name__ == "__main__":
raise SystemExit(main())
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Protocol
class Clock(Protocol):
def now(self) -> datetime: ...
class SystemClock:
def now(self) -> datetime:
return datetime.now(UTC)
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import os
import stat
from pathlib import Path
from typing import Literal, Self
from urllib.parse import urlsplit
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy.engine import make_url
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BACKUP_TOOL_",
extra="forbid",
case_sensitive=False,
)
data_dir: Path = Path("/var/lib/backup-tool")
web_socket_path: Path = Path("/tmp/backup-tool-web.sock")
database_url: str = "sqlite+aiosqlite:////var/lib/backup-tool/metadata.db"
repository_roots: tuple[Path, ...]
local_source_roots: tuple[Path, ...]
restore_roots: tuple[Path, ...]
master_key_file: Path
public_base_url: str = "http://127.0.0.1:8000"
bootstrap_secret: str | None = Field(default=None, min_length=16)
session_ttl_seconds: int = Field(default=28_800, ge=60, le=2_592_000)
cors_origins: tuple[str, ...] = ()
worker_concurrency: Literal[1] = 1
notification_delivery_lease_seconds: int = Field(default=60, ge=5, le=3600)
notification_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60)
notification_read_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
notification_max_attempts: int = Field(default=5, ge=1, le=20)
notification_retry_cap_seconds: int = Field(default=3600, ge=1, le=86_400)
notification_global_rate_per_minute: int = Field(default=120, ge=1, le=10_000)
notification_default_rate_per_minute: int = Field(default=60, ge=1, le=10_000)
notification_max_webhook_body_bytes: int = Field(default=65_536, ge=256, le=1_048_576)
notification_max_response_bytes: int = Field(default=16_384, ge=256, le=1_048_576)
ssh_connect_timeout_seconds: float = Field(default=5.0, gt=0, le=60)
ssh_operation_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
ssh_read_chunk_bytes: int = Field(default=1_048_576, ge=4096, le=8_388_608)
ssh_list_read_aheads: int = Field(default=32, ge=1, le=256)
ssh_max_entries: int = Field(default=1_000_000, ge=1, le=10_000_000)
ssh_max_traversal_depth: int = Field(default=128, ge=1, le=1024)
min_free_bytes: int = Field(default=1_073_741_824, ge=0)
min_free_percent: int = Field(default=5, gt=0, lt=100)
sqlite_busy_timeout_ms: int = Field(default=5_000, ge=1_000, le=120_000)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
@field_validator(
"data_dir",
"master_key_file",
"web_socket_path",
mode="before",
)
@classmethod
def validate_absolute_path(cls, value: object) -> Path:
path = Path(str(value)).expanduser()
if not path.is_absolute():
raise ValueError("path must be absolute")
return path.resolve()
@field_validator("repository_roots", "local_source_roots", "restore_roots", mode="before")
@classmethod
def validate_roots(cls, value: object) -> tuple[Path, ...]:
if isinstance(value, (str, Path)):
raw_roots = [value]
elif isinstance(value, (list, tuple, set)):
raw_roots = list(value)
else:
raise ValueError("allowlist roots must be a sequence of paths")
roots: list[Path] = []
for raw_root in raw_roots:
root = Path(str(raw_root)).expanduser()
if not root.is_absolute():
raise ValueError("allowlist roots must be absolute")
roots.append(root.resolve())
if not roots:
raise ValueError("at least one allowlist root is required")
if len(set(roots)) != len(roots):
raise ValueError("allowlist roots must be unique")
return tuple(roots)
@field_validator("database_url")
@classmethod
def validate_database_url(cls, value: str) -> str:
url = make_url(value)
if url.drivername != "sqlite+aiosqlite":
raise ValueError("v2 requires sqlite+aiosqlite")
if not url.database or not Path(url.database).is_absolute():
raise ValueError("SQLite database path must be absolute")
return value
@field_validator("public_base_url")
@classmethod
def validate_public_base_url(cls, value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("public base URL must be an absolute HTTP(S) URL")
if parsed.query or parsed.fragment:
raise ValueError("public base URL must not contain query or fragment")
return value.rstrip("/")
@model_validator(mode="after")
def validate_master_key(self) -> Self:
key = self.master_key_file
if not key.is_file():
raise ValueError("master key file must exist and be a regular file")
key_stat = key.stat()
if hasattr(os, "getuid") and key_stat.st_uid != os.getuid():
raise ValueError("master key file must be owned by the service user")
mode = stat.S_IMODE(key_stat.st_mode)
if mode != 0o600:
raise ValueError("master key file permissions must be 0600")
if key_stat.st_size < 32:
raise ValueError("master key file must contain at least 32 bytes")
return self
@property
def setup_requires_bootstrap(self) -> bool:
hostname = urlsplit(self.public_base_url).hostname
return hostname not in {"127.0.0.1", "::1", "localhost"}
@property
def database_path(self) -> Path:
database = make_url(self.database_url).database
if database is None: # pragma: no cover - guarded by validation
raise RuntimeError("database path is unavailable")
return Path(database).resolve()
+4
View File
@@ -0,0 +1,4 @@
from .engine import SchemaNotCurrentError, assert_schema_current, create_engine
from .models import Base
__all__ = ["Base", "SchemaNotCurrentError", "assert_schema_current", "create_engine"]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Any
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy import event
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from backup_tool.config import Settings
from . import sqlite as sqlite_runtime
class SchemaNotCurrentError(RuntimeError):
pass
def create_engine(settings: Settings) -> AsyncEngine:
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
connect_args=sqlite_runtime.sqlite_connect_args(settings.sqlite_busy_timeout_ms),
)
@event.listens_for(engine.sync_engine, "connect")
def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None:
sqlite_runtime.configure_sqlite_connection(dbapi_connection)
return engine
async def assert_schema_current(engine: AsyncEngine, alembic_config: Config) -> None:
expected = ScriptDirectory.from_config(alembic_config).get_current_head()
def get_current_revision(connection: Connection) -> str | None:
return MigrationContext.configure(connection).get_current_revision()
async with engine.connect() as connection:
current = await connection.run_sync(get_current_revision)
if current != expected:
raise SchemaNotCurrentError(
f"database schema is not current: expected {expected!r}, found {current!r}"
)
+451
View File
@@ -0,0 +1,451 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import (
JSON,
Boolean,
CheckConstraint,
ForeignKey,
Index,
Integer,
LargeBinary,
MetaData,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from backup_tool.ids import new_uuid7
from .types import UTCDateTime
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class IdentityMixin:
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(new_uuid7()))
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime(),
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
)
class User(IdentityMixin, TimestampMixin, Base):
__tablename__ = "users"
username: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
__table_args__ = (CheckConstraint("state IN ('active','disabled')", name="state"),)
class ApiToken(IdentityMixin, TimestampMixin, Base):
__tablename__ = "api_tokens"
owner_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),)
class Session(IdentityMixin, TimestampMixin, Base):
__tablename__ = "sessions"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (Index("ix_sessions_user_id", "user_id"),)
class Secret(IdentityMixin, TimestampMixin, Base):
__tablename__ = "secrets"
ciphertext: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
key_id: Mapped[str] = mapped_column(String(255), nullable=False)
purpose: Mapped[str] = mapped_column(String(64), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (CheckConstraint("version > 0", name="version_positive"),)
class Repository(IdentityMixin, TimestampMixin, Base):
__tablename__ = "repositories"
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
root: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
format_version: Mapped[int] = mapped_column(Integer, nullable=False)
compression: Mapped[str] = mapped_column(String(32), nullable=False)
encryption: Mapped[str] = mapped_column(String(32), nullable=False)
signing_key_id: Mapped[str] = mapped_column(String(64), nullable=False, server_default="")
signing_public_key: Mapped[str] = mapped_column(String(64), nullable=False, server_default="")
active_data_key_id: Mapped[str | None] = mapped_column(String(36))
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
__table_args__ = (
CheckConstraint("format_version > 0", name="format_version_positive"),
CheckConstraint("state IN ('active','archived','unavailable')", name="state"),
)
class RepositoryDataKeyEpoch(IdentityMixin, TimestampMixin, Base):
__tablename__ = "repository_data_key_epochs"
repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT"))
key_id: Mapped[str] = mapped_column(String(36), nullable=False)
state: Mapped[str] = mapped_column(String(16), nullable=False)
retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
UniqueConstraint("repository_id", "key_id", name="repository_key_epoch"),
CheckConstraint("state IN ('active','retired')", name="repository_data_key_epoch_state"),
Index("ix_repository_data_key_epochs_repository_id", "repository_id"),
Index(
"uq_repository_data_key_epochs_active",
"repository_id",
unique=True,
sqlite_where=text("state = 'active'"),
),
)
class Source(IdentityMixin, TimestampMixin, Base):
__tablename__ = "sources"
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
public_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
secret_refs: Mapped[list[str]] = mapped_column(JSON, nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
last_probe: Mapped[dict[str, Any] | None] = mapped_column(JSON)
__table_args__ = (
CheckConstraint("kind IN ('local','ssh')", name="kind"),
CheckConstraint("state IN ('active','archived','unavailable')", name="state"),
)
class Job(IdentityMixin, TimestampMixin, Base):
__tablename__ = "jobs"
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
source_id: Mapped[str] = mapped_column(ForeignKey("sources.id", ondelete="RESTRICT"))
repository_id: Mapped[str] = mapped_column(ForeignKey("repositories.id", ondelete="RESTRICT"))
requested_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="incremental")
exclusions: Mapped[list[str]] = mapped_column(JSON, nullable=False)
retention: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
allow_empty: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
__table_args__ = (
CheckConstraint("requested_mode IN ('full','incremental')", name="requested_mode"),
CheckConstraint("state IN ('active','archived')", name="state"),
Index("ix_jobs_source_id", "source_id"),
Index("ix_jobs_repository_id", "repository_id"),
)
class Schedule(IdentityMixin, TimestampMixin, Base):
__tablename__ = "schedules"
job_id: Mapped[str] = mapped_column(
ForeignKey("jobs.id", ondelete="RESTRICT"), nullable=False, unique=True
)
cron: Mapped[str] = mapped_column(String(255), nullable=False)
timezone: Mapped[str] = mapped_column(String(255), nullable=False)
misfire_grace_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=900)
overlap_policy: Mapped[str] = mapped_column(String(32), nullable=False, default="prohibit")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
next_nominal_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
last_enqueue_outcome: Mapped[str | None] = mapped_column(String(64))
__table_args__ = (
CheckConstraint("misfire_grace_seconds >= 0", name="misfire_nonnegative"),
CheckConstraint("overlap_policy IN ('prohibit')", name="overlap_policy"),
)
class Execution(IdentityMixin, TimestampMixin, Base):
__tablename__ = "executions"
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="RESTRICT"))
schedule_id: Mapped[str | None] = mapped_column(ForeignKey("schedules.id", ondelete="RESTRICT"))
nominal_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
trigger: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
lease_owner: Mapped[str | None] = mapped_column(String(255))
lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
heartbeat_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
progress: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reason_code: Mapped[str | None] = mapped_column(String(64))
operator_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
CheckConstraint("attempt > 0", name="attempt_positive"),
CheckConstraint(
"state IN ('queued','preparing','running','verifying','committed',"
"'cancelling','cancelled','failed')",
name="state",
),
CheckConstraint("trigger IN ('manual','schedule','retry')", name="trigger"),
UniqueConstraint("schedule_id", "nominal_run_at", name="uq_execution_schedule_occurrence"),
Index("ix_executions_job_id", "job_id"),
Index("ix_executions_state_created", "state", "created_at"),
Index(
"uq_executions_active_job",
"job_id",
unique=True,
sqlite_where=text("state IN ('queued','preparing','running','verifying','cancelling')"),
),
)
class ExecutionEvent(Base):
__tablename__ = "execution_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
execution_id: Mapped[str] = mapped_column(
ForeignKey("executions.id", ondelete="CASCADE"), nullable=False
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
__table_args__ = (
UniqueConstraint("execution_id", "revision", name="uq_execution_event_revision"),
)
class Backup(IdentityMixin, Base):
__tablename__ = "backups"
execution_id: Mapped[str] = mapped_column(
ForeignKey("executions.id", ondelete="RESTRICT"), nullable=False, unique=True
)
parent_backup_id: Mapped[str | None] = mapped_column(
ForeignKey("backups.id", ondelete="RESTRICT")
)
manifest_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True)
manifest_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
logical_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
stored_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
integrity: Mapped[str] = mapped_column(String(32), nullable=False, default="unverified")
data_key_id: Mapped[str | None] = mapped_column(String(36))
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tombstoned_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
created_at: Mapped[datetime] = mapped_column(
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
CheckConstraint("logical_bytes >= 0", name="logical_bytes_nonnegative"),
CheckConstraint("stored_bytes >= 0", name="stored_bytes_nonnegative"),
CheckConstraint(
"integrity IN ('unverified','verified','degraded','corrupt')", name="integrity"
),
Index("ix_backups_created_at", "created_at"),
)
class Restore(IdentityMixin, TimestampMixin, Base):
__tablename__ = "restores"
backup_id: Mapped[str] = mapped_column(ForeignKey("backups.id", ondelete="RESTRICT"))
destination: Mapped[str] = mapped_column(Text, nullable=False)
selection: Mapped[list[str]] = mapped_column(JSON, nullable=False)
dry_run: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
overwrite_policy: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
result: Mapped[dict[str, Any] | None] = mapped_column(JSON)
__table_args__ = (
CheckConstraint("overwrite_policy IN ('fail','skip','replace')", name="overwrite_policy"),
CheckConstraint(
"state IN ('queued','running','committed','cancelled','failed')", name="state"
),
Index("ix_restores_backup_id", "backup_id"),
)
class AuditEvent(IdentityMixin, Base):
__tablename__ = "audit_events"
actor_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
action: Mapped[str] = mapped_column(String(255), nullable=False)
resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
resource_id: Mapped[str | None] = mapped_column(String(36))
outcome: Mapped[str] = mapped_column(String(32), nullable=False)
request_id: Mapped[str] = mapped_column(String(36), nullable=False)
details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
CheckConstraint("outcome IN ('success','failure','denied')", name="outcome"),
Index("ix_audit_events_created_at", "created_at"),
Index("ix_audit_events_actor_id", "actor_id"),
)
class NotificationSubscription(IdentityMixin, TimestampMixin, Base):
"""A typed, mutable outbound channel configuration; credential material is never here."""
__tablename__ = "notification_subscriptions"
channel: Mapped[str] = mapped_column(String(32), nullable=False)
event_filters: Mapped[list[str]] = mapped_column(JSON, nullable=False)
destination_config: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
# Retained only to make the migration from the unused v1-shaped table lossless.
# New webhook secrets live in NotificationSigningKey and SMTP secrets in settings.
secret_id: Mapped[str | None] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT"))
state: Mapped[str] = mapped_column(String(32), nullable=False, default="active")
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
rate_tokens: Mapped[float] = mapped_column(nullable=False, default=60.0)
rate_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (
CheckConstraint("channel IN ('webhook','email')", name="channel"),
CheckConstraint("state IN ('active','disabled','archived')", name="state"),
CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"),
CheckConstraint("rate_tokens >= 0", name="rate_tokens_nonnegative"),
CheckConstraint("revision > 0", name="revision_positive"),
)
class NotificationEvent(IdentityMixin, Base):
"""Immutable canonical outbox envelope, created in the source mutation transaction."""
__tablename__ = "notification_events"
type: Mapped[str] = mapped_column(String(96), nullable=False)
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
correlation_id: Mapped[str] = mapped_column(String(36), nullable=False)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
resource_refs: Mapped[dict[str, str]] = mapped_column(JSON, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
canonical_envelope: Mapped[str] = mapped_column(Text, nullable=False)
deduplication_key: Mapped[str | None] = mapped_column(String(255), unique=True)
__table_args__ = (
CheckConstraint("schema_version = 1", name="schema_version"),
CheckConstraint("severity IN ('info','warning','error','security')", name="severity"),
Index("ix_notification_events_type_occurred", "type", "occurred_at"),
)
class NotificationDelivery(IdentityMixin, TimestampMixin, Base):
"""One durable delivery per matching subscription, with a lease-owned lifecycle."""
__tablename__ = "notification_deliveries"
event_id: Mapped[str] = mapped_column(
ForeignKey("notification_events.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
due_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
lease_owner: Mapped[str | None] = mapped_column(String(255))
lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
terminal_reason: Mapped[str | None] = mapped_column(String(96))
response_class: Mapped[str | None] = mapped_column(String(64))
response_summary: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (
CheckConstraint("attempt_count >= 0", name="attempt_count_nonnegative"),
CheckConstraint("state IN ('pending','leased','delivered','retry','failed')", name="state"),
UniqueConstraint("event_id", "subscription_id", name="event_subscription"),
Index("ix_notification_deliveries_due", "state", "due_at"),
Index("ix_notification_deliveries_lease", "state", "lease_expires_at"),
)
class NotificationDeliveryAttempt(IdentityMixin, Base):
"""Append-only history. Diagnostics are bounded/redacted before persistence."""
__tablename__ = "notification_delivery_attempts"
delivery_id: Mapped[str] = mapped_column(
ForeignKey("notification_deliveries.id", ondelete="RESTRICT"), nullable=False
)
number: Mapped[int] = mapped_column(Integer, nullable=False)
started_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
outcome: Mapped[str] = mapped_column(String(32), nullable=False, default="started")
response_class: Mapped[str | None] = mapped_column(String(64))
diagnostic: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (
CheckConstraint("number > 0", name="number_positive"),
CheckConstraint("outcome IN ('started','delivered','retry','failed')", name="outcome"),
UniqueConstraint("delivery_id", "number", name="delivery_number"),
Index("ix_notification_attempts_delivery", "delivery_id", "number"),
)
class NotificationSigningKey(IdentityMixin, TimestampMixin, Base):
__tablename__ = "notification_signing_keys"
subscription_id: Mapped[str] = mapped_column(
ForeignKey("notification_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
secret_id: Mapped[str] = mapped_column(ForeignKey("secrets.id", ondelete="RESTRICT"))
state: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
overlap_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
CheckConstraint("version > 0", name="version_positive"),
CheckConstraint("state IN ('active','overlap','retired')", name="state"),
UniqueConstraint("subscription_id", "version", name="subscription_version"),
Index("ix_notification_signing_keys_subscription", "subscription_id", "state"),
Index(
"uq_notification_signing_keys_active",
"subscription_id",
unique=True,
sqlite_where=text("state = 'active'"),
),
Index(
"uq_notification_signing_keys_overlap",
"subscription_id",
unique=True,
sqlite_where=text("state = 'overlap'"),
),
)
class NotificationEmailSettings(Base):
__tablename__ = "notification_email_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
host: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
username: Mapped[str] = mapped_column(String(255), nullable=False)
password_secret_id: Mapped[str] = mapped_column(
ForeignKey("secrets.id", ondelete="RESTRICT"), nullable=False
)
sender: Mapped[str] = mapped_column(String(320), nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
rate_limit_per_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
__table_args__ = (
CheckConstraint("id = 1", name="singleton"),
CheckConstraint("port > 0 AND port < 65536", name="port"),
CheckConstraint("max_attempts > 0", name="max_attempts"),
CheckConstraint("rate_limit_per_minute > 0", name="rate_positive"),
)
class IdempotencyRecord(IdentityMixin, Base):
__tablename__ = "idempotency_records"
actor_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
key: Mapped[str] = mapped_column(String(255), nullable=False)
operation: Mapped[str] = mapped_column(String(255), nullable=False)
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
response_resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
response_resource_id: Mapped[str] = mapped_column(String(36), nullable=False)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
UniqueConstraint("actor_id", "key", "operation", name="uq_idempotency_actor_key_operation"),
Index("ix_idempotency_created_at", "created_at"),
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any
DEFAULT_BUSY_TIMEOUT_MS = 5_000
def sqlite_connect_args(busy_timeout_ms: int) -> dict[str, float]:
"""Configure SQLite lock waiting without dynamic PRAGMA SQL."""
return {"timeout": busy_timeout_ms / 1_000}
def configure_sqlite_connection(dbapi_connection: Any) -> None:
"""Apply connection-local safety and persistent WAL mode."""
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
finally:
cursor.close()
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import DateTime
from sqlalchemy.engine import Dialect
from sqlalchemy.types import TypeDecorator
class UTCDateTime(TypeDecorator[datetime]):
"""Store instants and always return timezone-aware UTC datetimes."""
impl = DateTime
cache_ok = True
def load_dialect_impl(self, dialect: Dialect) -> Any:
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
del dialect
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime values must be timezone-aware")
return value.astimezone(UTC)
def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None:
del dialect
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from pathlib import PurePosixPath
class ExclusionError(ValueError):
pass
def _validate(value: str) -> None:
path = PurePosixPath(value)
if not value or "\\" in value or path.is_absolute() or ".." in path.parts:
raise ExclusionError("exclusion paths must be normalized relative POSIX paths")
def matches(path: str, patterns: list[str]) -> bool:
"""Return whether normalized relative `path` is excluded by ordered gitignore-like rules."""
_validate(path)
excluded = False
candidate = PurePosixPath(path)
for pattern in patterns:
negated = pattern.startswith("!")
raw = pattern[1:] if negated else pattern
if not raw or raw.startswith("/") or "\\" in raw or ".." in PurePosixPath(raw).parts:
raise ExclusionError("exclusion pattern is invalid")
directory = raw.endswith("/")
raw = raw.rstrip("/")
if not raw:
raise ExclusionError("exclusion pattern is invalid")
matched = candidate.match(raw) or candidate.match(f"**/{raw}")
if directory:
matched = matched or any(
parent.match(raw) or parent.match(f"**/{raw}") for parent in candidate.parents
)
if matched:
excluded = not negated
return excluded
+306
View File
@@ -0,0 +1,306 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import Execution, ExecutionEvent, Job
from backup_tool.notifications.events import emit_event
from backup_tool.security.redaction import redact
ACTIVE_STATES = frozenset({"queued", "preparing", "running", "verifying", "cancelling"})
TERMINAL_STATES = frozenset({"committed", "cancelled", "failed"})
TRANSIENT_REASONS = frozenset({"worker_lost", "timeout", "unavailable", "transient_io"})
_ALLOWED: Mapping[str, frozenset[str]] = {
"queued": frozenset({"preparing", "cancelled", "failed"}),
"preparing": frozenset({"running", "cancelling", "failed"}),
"running": frozenset({"verifying", "cancelling", "failed"}),
"verifying": frozenset({"committed", "failed"}),
"cancelling": frozenset({"cancelled", "failed"}),
"committed": frozenset(),
"cancelled": frozenset(),
"failed": frozenset(),
}
class TransitionError(ValueError):
code = "invalid_transition"
class EnqueueError(ValueError):
def __init__(self, code: str, detail: str, active_execution_id: str | None = None):
super().__init__(detail)
self.code = code
self.active_execution_id = active_execution_id
def transition(current: str, target: str) -> str:
"""Validate and return one durable execution-state transition."""
if target not in _ALLOWED.get(current, frozenset()):
raise TransitionError(f"Cannot transition execution from {current!r} to {target!r}.")
return target
async def enqueue(
db: AsyncSession,
job_id: str,
trigger: str = "manual",
*,
schedule_id: str | None = None,
nominal_run_at: datetime | None = None,
) -> Execution:
"""Create exactly one active execution for an enabled active job."""
job = await db.get(Job, job_id)
if job is None:
raise EnqueueError("resource_not_found", "Job was not found.")
if job.state != "active" or not job.enabled:
raise EnqueueError("job_disabled", "Job is disabled or archived.")
job_identifier = job.id
execution = Execution(
job_id=job_identifier,
trigger=trigger,
schedule_id=schedule_id,
nominal_run_at=nominal_run_at,
progress={},
)
db.add(execution)
try:
await db.flush()
await record_event(db, execution)
await db.commit()
except IntegrityError as error:
await db.rollback()
existing = await db.scalar(
select(Execution.id).where(
Execution.job_id == job_identifier, Execution.state.in_(ACTIVE_STATES)
)
)
raise EnqueueError(
"execution_active", "Job already has an active execution.", existing
) from error
await db.refresh(execution)
return execution
async def claim(
db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60
) -> Execution | None:
"""Atomically lease a queued execution; a stale lease may be recovered."""
now = datetime.now(UTC)
expires = now + timedelta(seconds=lease_seconds)
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.state.in_({"queued", "preparing"}),
(Execution.lease_expires_at.is_(None)) | (Execution.lease_expires_at < now),
)
.values(state="preparing", lease_owner=owner, lease_expires_at=expires, heartbeat_at=now)
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
execution = await db.get(Execution, execution_id)
if execution is not None:
await record_event(db, execution)
await db.commit()
return execution
async def heartbeat(
db: AsyncSession, execution_id: str, owner: str, lease_seconds: int = 60
) -> bool:
now = datetime.now(UTC)
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.lease_owner == owner,
Execution.state.in_(ACTIVE_STATES),
Execution.lease_expires_at >= now,
)
.values(heartbeat_at=now, lease_expires_at=now + timedelta(seconds=lease_seconds))
)
await db.commit()
return getattr(result, "rowcount", 0) == 1
async def request_cancellation(db: AsyncSession, execution_id: str) -> Execution | None:
# Queued work has no worker and may terminate immediately; leased work asks its owner.
now = datetime.now(UTC)
result = await db.execute(
update(Execution)
.where(Execution.id == execution_id, Execution.state == "queued")
.values(state="cancelled", reason_code="cancellation_requested", completed_at=now)
)
if getattr(result, "rowcount", 0) != 1:
result = await db.execute(
update(Execution)
.where(Execution.id == execution_id, Execution.state.in_({"preparing", "running"}))
.values(state="cancelling", reason_code="cancellation_requested")
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
execution = await db.get(Execution, execution_id)
if execution is not None:
await record_event(db, execution)
await db.commit()
return execution
async def complete_cancellation(db: AsyncSession, execution_id: str, owner: str) -> bool:
result = await db.execute(
update(Execution)
.where(
Execution.id == execution_id,
Execution.lease_owner == owner,
Execution.lease_expires_at >= datetime.now(UTC),
Execution.state == "cancelling",
)
.values(
state="cancelled",
completed_at=datetime.now(UTC),
lease_owner=None,
lease_expires_at=None,
)
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return False
execution = await db.get(Execution, execution_id)
if execution is not None:
await record_event(db, execution)
await db.commit()
return True
async def retry(db: AsyncSession, execution_id: str) -> Execution | None:
execution = await db.get(Execution, execution_id)
if execution is None:
return None
if execution.state != "failed" or execution.reason_code not in TRANSIENT_REASONS:
raise EnqueueError("retry_not_allowed", "Execution failure is not retryable.")
execution.state = "queued"
execution.attempt += 1
execution.lease_owner = None
execution.lease_expires_at = None
execution.heartbeat_at = None
execution.reason_code = None
execution.operator_message = None
await record_event(db, execution)
try:
await db.commit()
except IntegrityError as error:
await db.rollback()
active_id = await db.scalar(
select(Execution.id).where(
Execution.job_id == execution.job_id, Execution.state.in_(ACTIVE_STATES)
)
)
raise EnqueueError(
"execution_active", "Job already has an active execution.", active_id
) from error
await db.refresh(execution)
return execution
def _redact_progress(value: object) -> object:
"""Apply common redaction plus execution-specific path redaction recursively."""
if isinstance(value, Mapping):
safe: dict[str, object] = {}
for key, item in value.items():
normalized = str(key).lower()
if any(marker in normalized for marker in ("path", "secret", "token", "password")):
safe[str(key)] = "[REDACTED]"
else:
safe[str(key)] = _redact_progress(item)
return safe
if isinstance(value, list):
return [_redact_progress(item) for item in value]
return redact(value)
async def record_event(db: AsyncSession, execution: Execution) -> ExecutionEvent:
"""Append the public representation as the next durable event revision."""
execution.revision += 1
event = ExecutionEvent(
execution_id=execution.id,
revision=execution.revision,
payload=public_event(execution),
)
db.add(event)
await db.flush()
notification_type = {
"preparing": "execution.started",
"committed": "execution.committed",
"failed": "execution.failed",
"cancelled": "execution.cancelled",
}.get(execution.state)
if execution.state == "queued":
if execution.reason_code == "worker_lost":
notification_type = "execution.worker_recovered"
elif execution.attempt > 1:
notification_type = "execution.retry_queued"
else:
notification_type = "execution.queued"
if notification_type is not None:
await emit_event(
db,
notification_type,
correlation_id=execution.id,
resource={"execution_id": execution.id, "job_id": execution.job_id},
payload={
"attempt": execution.attempt,
"state": execution.state,
"reason_code": execution.reason_code,
},
deduplication_key=f"execution:{execution.id}:revision:{execution.revision}",
)
return event
def public_event(execution: Execution) -> dict[str, object]:
"""Return redacted progress suitable for polling or SSE."""
progress = _redact_progress(execution.progress)
return {
"id": execution.id,
"state": execution.state,
"attempt": execution.attempt,
"revision": execution.revision,
"reason_code": execution.reason_code,
"progress": progress,
}
async def recover_stale(db: AsyncSession) -> int:
"""Atomically recover expired executions and append one event per mutation."""
now = datetime.now(UTC)
executions = list(
(
await db.scalars(
select(Execution).where(
Execution.state.in_({"preparing", "running", "verifying", "cancelling"}),
Execution.lease_expires_at < now,
)
)
).all()
)
for execution in executions:
if execution.state == "cancelling":
execution.state = "cancelled"
execution.completed_at = now
execution.reason_code = "cancellation_requested"
else:
execution.state = "queued"
execution.reason_code = "worker_lost"
execution.lease_owner = None
execution.lease_expires_at = None
execution.heartbeat_at = None
await record_event(db, execution)
await db.commit()
return len(executions)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Protocol
class FaultInjector(Protocol):
def hit(self, point: str) -> None: ...
class NoFault:
def hit(self, point: str) -> None:
del point
class InjectedCrash(BaseException):
"""Test-only abrupt worker termination at a named durable fault point."""
class CrashAt:
def __init__(self, point: str) -> None:
self.point = point
def hit(self, point: str) -> None:
if point == self.point:
raise InjectedCrash(point)
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import json
import shutil
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import Backup, Execution, Job, Repository
from backup_tool.notifications.events import emit_event
from backup_tool.retention import BackupLike, RetentionPolicy, retained_ids
from backup_tool.security.repository_crypto import RepositoryKeyError, decrypt_object, object_aad
@dataclass(frozen=True)
class GcReport:
tombstoned: int
purged_manifests: int
purged_blobs: int
quarantined: int
async def tombstone_expired(db: AsyncSession, now: datetime | None = None) -> int:
reference = now or datetime.now(UTC)
backups = list((await db.scalars(select(Backup))).all())
executions = {item.id: item for item in (await db.scalars(select(Execution))).all()}
jobs = {item.id: item for item in (await db.scalars(select(Job))).all()}
grouped: dict[str, list[Backup]] = {}
for backup in backups:
execution = executions.get(backup.execution_id)
if execution is None or execution.job_id not in jobs:
continue
grouped.setdefault(execution.job_id, []).append(backup)
tombstoned = 0
for job_id, items in grouped.items():
if not jobs[job_id].retention:
continue
policy = RetentionPolicy.from_dict(jobs[job_id].retention)
keep = retained_ids(cast(list[BackupLike], items), policy, reference)
for backup in items:
if backup.id not in keep and backup.tombstoned_at is None:
backup.tombstoned_at = reference
await emit_event(
db,
"retention.tombstoned",
correlation_id=backup.id,
resource={"backup_id": backup.id, "job_id": job_id},
payload={"outcome": "tombstoned"},
deduplication_key=f"backup:{backup.id}:tombstoned",
)
tombstoned += 1
await db.commit()
return tombstoned
async def process_retention_gc(db: AsyncSession, now: datetime | None = None) -> GcReport:
"""Run durable retention tombstoning and repository GC from the worker role."""
reference = now or datetime.now(UTC)
tombstoned = await tombstone_expired(db, reference)
reports: list[GcReport] = []
repositories = list((await db.scalars(select(Repository))).all())
for repository in repositories:
manifest_ids = set(
(
await db.scalars(
select(Backup.manifest_id)
.join(Execution, Backup.execution_id == Execution.id)
.join(Job, Execution.job_id == Job.id)
.where(
Job.repository_id == repository.id,
Backup.tombstoned_at.is_not(None),
)
)
).all()
)
if manifest_ids:
reports.append(purge_repository(Path(repository.root), manifest_ids, now=reference))
return GcReport(
tombstoned=tombstoned,
purged_manifests=sum(report.purged_manifests for report in reports),
purged_blobs=sum(report.purged_blobs for report in reports),
quarantined=sum(report.quarantined for report in reports),
)
def _manifest_digests(
path: Path,
*,
repository_id: str | None = None,
manifest_keys: Mapping[str, tuple[str, bytes]] | None = None,
) -> set[str] | None:
try:
if path.is_symlink() or not path.is_file():
return None
raw = path.read_bytes()
if raw.startswith(b"BTENC\x01"):
key_record = manifest_keys.get(path.stem) if manifest_keys is not None else None
if key_record is None or repository_id is None:
return None
key_id, key = key_record
raw = decrypt_object(key, object_aad(repository_id, key_id, "manifest", path.stem), raw)
payload = json.loads(raw.decode("utf-8"))
except (OSError, RepositoryKeyError, UnicodeDecodeError, json.JSONDecodeError):
return None
if not isinstance(payload, dict) or not isinstance(entries := payload.get("entries"), list):
return None
digests: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
return None
digest = entry.get("blob_digest")
if digest is None:
continue
if (
not isinstance(digest, str)
or len(digest) != 64
or any(character not in "0123456789abcdef" for character in digest)
):
return None
digests.add(digest)
return digests
def purge_repository(
root: Path,
tombstoned_manifest_ids: set[str],
*,
repository_id: str | None = None,
manifest_keys: Mapping[str, tuple[str, bytes]] | None = None,
grace: timedelta = timedelta(days=7),
now: datetime | None = None,
) -> GcReport:
reference = now or datetime.now(UTC)
manifests = root / "manifests"
blobs = root / "blobs" / "sha256"
quarantine = root / "quarantine"
quarantine.mkdir(exist_ok=True)
manifest_digests: dict[Path, set[str]] = {}
for manifest in manifests.glob("*.json"):
digests = _manifest_digests(
manifest,
repository_id=repository_id,
manifest_keys=manifest_keys,
)
if digests is None:
return GcReport(0, 0, 0, 0)
manifest_digests[manifest] = digests
purged_manifests = 0
for manifest_id in tombstoned_manifest_ids:
candidate = manifests / f"{manifest_id}.json"
if not candidate.is_file() or candidate.is_symlink():
continue
age = reference - datetime.fromtimestamp(candidate.stat().st_mtime, UTC)
if age >= grace:
candidate.unlink()
purged_manifests += 1
referenced: set[str] = set()
for manifest, digests in manifest_digests.items():
if manifest.exists():
referenced.update(digests)
purged_blobs = 0
quarantined = 0
if blobs.exists():
for blob in blobs.iterdir():
if not blob.is_file():
continue
if len(blob.name) != 64 or any(char not in "0123456789abcdef" for char in blob.name):
try:
shutil.move(str(blob), quarantine / blob.name)
except OSError:
continue
quarantined += 1
elif blob.name not in referenced and (
reference - datetime.fromtimestamp(blob.stat().st_mtime, UTC) >= grace
):
blob.unlink()
purged_blobs += 1
return GcReport(0, purged_manifests, purged_blobs, quarantined)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import secrets
import threading
import time
from uuid import UUID
_lock = threading.Lock()
_last_millisecond = -1
_last_random = 0
_RANDOM_MASK = (1 << 74) - 1
def new_uuid7() -> UUID:
"""Return a process-monotonic RFC 9562 UUIDv7."""
global _last_millisecond, _last_random
with _lock:
millisecond = time.time_ns() // 1_000_000
if millisecond > _last_millisecond:
_last_millisecond = millisecond
_last_random = secrets.randbits(74)
else:
millisecond = _last_millisecond
_last_random = (_last_random + 1) & _RANDOM_MASK
if _last_random == 0:
_last_millisecond += 1
millisecond = _last_millisecond
random_a = (_last_random >> 62) & 0xFFF
random_b = _last_random & ((1 << 62) - 1)
integer = (millisecond & ((1 << 48) - 1)) << 80
integer |= 0x7 << 76
integer |= random_a << 64
integer |= 0b10 << 62
integer |= random_b
return UUID(int=integer)
@@ -0,0 +1,5 @@
"""Durable, worker-dispatched operational notifications."""
from .events import EVENT_CATALOG, emit_event, validate_filters
__all__ = ["EVENT_CATALOG", "emit_event", "validate_filters"]
@@ -0,0 +1,335 @@
"""Worker-owned leased outbox dispatcher; sends happen only after a committed lease."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.db.models import (
NotificationDelivery,
NotificationDeliveryAttempt,
NotificationEmailSettings,
NotificationEvent,
NotificationSigningKey,
NotificationSubscription,
Secret,
)
from backup_tool.notifications.email import EmailTransportError, deliver_email
from backup_tool.notifications.retry import (
RetryDecision,
retry_delay,
transport_decision,
webhook_decision,
)
from backup_tool.notifications.webhook import (
SigningMaterial,
WebhookTransportError,
deliver_webhook,
)
from backup_tool.security.secrets import EnvelopeCipher
from backup_tool.security.ssrf import Resolver, system_resolver
async def recover_notification_leases(db: AsyncSession) -> int:
"""An interrupted post-send lease becomes eligible again (at-least-once by design)."""
now = datetime.now(UTC)
deliveries = list(
(
await db.scalars(
select(NotificationDelivery).where(
NotificationDelivery.state == "leased",
NotificationDelivery.lease_expires_at < now,
)
)
).all()
)
for delivery in deliveries:
attempt = await db.scalar(
select(NotificationDeliveryAttempt).where(
NotificationDeliveryAttempt.delivery_id == delivery.id,
NotificationDeliveryAttempt.number == delivery.attempt_count,
NotificationDeliveryAttempt.outcome == "started",
)
)
if attempt is not None:
attempt.completed_at = now
attempt.outcome = "retry"
attempt.response_class = "lease_expired"
attempt.diagnostic = "abandoned_lease"
delivery.state = "retry"
delivery.lease_owner = None
delivery.lease_expires_at = None
delivery.due_at = now
if deliveries:
await db.commit()
return len(deliveries)
async def _claim_due(
db: AsyncSession, owner: str, lease_seconds: int
) -> tuple[NotificationDelivery, NotificationSubscription, NotificationEvent] | None:
now = datetime.now(UTC)
delivery_id = await db.scalar(
select(NotificationDelivery.id)
.where(
NotificationDelivery.state.in_(("pending", "retry")),
NotificationDelivery.due_at <= now,
)
.order_by(NotificationDelivery.due_at, NotificationDelivery.created_at)
.limit(1)
)
if delivery_id is None:
return None
result = await db.execute(
update(NotificationDelivery)
.where(
NotificationDelivery.id == delivery_id,
NotificationDelivery.state.in_(("pending", "retry")),
NotificationDelivery.due_at <= now,
)
.values(
state="leased",
lease_owner=owner,
lease_expires_at=now + timedelta(seconds=lease_seconds),
attempt_count=NotificationDelivery.attempt_count + 1,
)
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return None
delivery = await db.get(NotificationDelivery, delivery_id)
if delivery is None: # pragma: no cover - guarded by update
await db.rollback()
return None
subscription = await db.get(NotificationSubscription, delivery.subscription_id)
event = await db.get(NotificationEvent, delivery.event_id)
if subscription is None or event is None or subscription.state != "active":
delivery.state = "failed"
delivery.terminal_reason = "subscription_unavailable"
delivery.lease_owner = None
delivery.lease_expires_at = None
await db.commit()
return None
# Persist a token bucket before starting an attempt, so restarts cannot bypass
# the subscription rate limit. Global process rate is intentionally a config
# ceiling; the durable subscription bucket protects cross-restart behavior.
last = subscription.rate_updated_at or now
elapsed = max(0.0, (now - last).total_seconds())
capacity = subscription.rate_limit_per_minute
try:
token_capacity = float(capacity)
tokens = min(token_capacity, subscription.rate_tokens + elapsed * capacity / 60)
except (TypeError, ValueError, ZeroDivisionError) as error:
raise RuntimeError("notification rate limit is invalid") from error
if tokens < 1:
try:
delay = max(1, int((1 - tokens) * 60 / capacity) + 1)
except (TypeError, ValueError, ZeroDivisionError) as error:
raise RuntimeError("notification rate limit is invalid") from error
delivery.state = "retry"
delivery.due_at = now + timedelta(seconds=delay)
delivery.lease_owner = None
delivery.lease_expires_at = None
subscription.rate_tokens = tokens
subscription.rate_updated_at = now
await db.commit()
return None
subscription.rate_tokens = tokens - 1
subscription.rate_updated_at = now
db.add(
NotificationDeliveryAttempt(
delivery_id=delivery.id,
number=delivery.attempt_count,
started_at=now,
outcome="started",
)
)
await db.commit()
return delivery, subscription, event
async def _finish(
db: AsyncSession,
delivery_id: str,
owner: str,
*,
delivered: bool,
retryable: bool,
response_class: str,
reason: str,
retry_cap: int,
max_attempts: int,
) -> None:
delivery = await db.get(NotificationDelivery, delivery_id)
if delivery is None or delivery.lease_owner != owner or delivery.state != "leased":
await db.rollback()
return
attempt = await db.scalar(
select(NotificationDeliveryAttempt).where(
NotificationDeliveryAttempt.delivery_id == delivery.id,
NotificationDeliveryAttempt.number == delivery.attempt_count,
)
)
if attempt is None: # pragma: no cover - an invariant of _claim_due
await db.rollback()
return
now = datetime.now(UTC)
attempt.completed_at = now
attempt.response_class = response_class
attempt.diagnostic = reason[:512]
delivery.response_class = response_class
delivery.response_summary = reason[:512]
delivery.lease_owner = None
delivery.lease_expires_at = None
if delivered:
delivery.state = "delivered"
attempt.outcome = "delivered"
elif retryable and delivery.attempt_count < max_attempts:
delivery.state = "retry"
delivery.due_at = now + timedelta(seconds=retry_delay(delivery.attempt_count, retry_cap))
attempt.outcome = "retry"
else:
delivery.state = "failed"
delivery.terminal_reason = reason
attempt.outcome = "failed"
await db.commit()
async def dispatch_one(
db: AsyncSession,
settings: Settings,
cipher: EnvelopeCipher,
owner: str,
*,
resolver: Resolver = system_resolver,
) -> bool:
claimed = await _claim_due(db, owner, settings.notification_delivery_lease_seconds)
if claimed is None:
return False
delivery, subscription, event = claimed
max_attempts = settings.notification_max_attempts
try:
if subscription.channel == "webhook":
key_rows = list(
(
await db.scalars(
select(NotificationSigningKey).where(
NotificationSigningKey.subscription_id == subscription.id,
NotificationSigningKey.state.in_(("active", "overlap")),
)
)
).all()
)
keys: list[SigningMaterial] = []
now = datetime.now(UTC)
for key in key_rows:
if (
key.state == "overlap"
and key.overlap_expires_at is not None
and key.overlap_expires_at <= now
):
key.state = "retired"
continue
secret = await db.get(Secret, key.secret_id)
if secret is None:
raise WebhookTransportError("webhook signing secret is unavailable")
keys.append(
SigningMaterial(
key_id=key.id,
version=key.version,
secret=cipher.decrypt(
secret.ciphertext,
purpose=secret.purpose,
version=secret.version,
),
)
)
await db.commit()
webhook_result = await deliver_webhook(
str(subscription.destination_config["url"]),
event.canonical_envelope.encode(),
event_id=event.id,
event_type=event.type,
timestamp=event.occurred_at.isoformat(),
keys=keys,
resolver=resolver,
connect_timeout=settings.notification_connect_timeout_seconds,
read_timeout=settings.notification_read_timeout_seconds,
max_response_bytes=settings.notification_max_response_bytes,
)
decision = webhook_decision(webhook_result.status_code)
elif subscription.channel == "email":
email_settings = await db.get(NotificationEmailSettings, 1)
if email_settings is None:
raise EmailTransportError("SMTP settings are unavailable")
max_attempts = email_settings.max_attempts
password_secret = await db.get(Secret, email_settings.password_secret_id)
if password_secret is None:
raise EmailTransportError("SMTP password is unavailable")
email_result = await deliver_email(
email_settings,
cipher.decrypt(
password_secret.ciphertext,
purpose=password_secret.purpose,
version=password_secret.version,
),
event,
subscription.destination_config["recipients"],
)
decision = RetryDecision(False, email_result.response_class, "delivered")
else: # guarded by DB constraint
raise WebhookTransportError("notification channel is unavailable")
await _finish(
db,
delivery.id,
owner,
delivered=decision.reason == "delivered",
retryable=decision.retry,
response_class=decision.response_class,
reason=decision.reason,
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except EmailTransportError as error:
decision = transport_decision(error.transient, "smtp_transport")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=decision.retry,
response_class=decision.response_class,
reason=str(error),
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except WebhookTransportError as error:
decision = transport_decision(error.transient, "webhook_transport")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=decision.retry,
response_class=decision.response_class,
reason=str(error),
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
except (KeyError, ValueError):
decision = transport_decision(False, "webhook_validation")
await _finish(
db,
delivery.id,
owner,
delivered=False,
retryable=False,
response_class=decision.response_class,
reason=decision.reason,
retry_cap=settings.notification_retry_cap_seconds,
max_attempts=max_attempts,
)
return True
@@ -0,0 +1,120 @@
"""Authenticated, certificate-verified STARTTLS email notification transport."""
from __future__ import annotations
import asyncio
import smtplib
import ssl
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from typing import Protocol, Self, cast
from backup_tool.db.models import NotificationEmailSettings, NotificationEvent
class SMTPClient(Protocol):
def __enter__(self) -> Self: ...
def __exit__(self, *args: object) -> None: ...
def ehlo(self) -> object: ...
def starttls(self, *, context: ssl.SSLContext) -> object: ...
def login(self, user: str, password: str) -> object: ...
def send_message(self, msg: EmailMessage) -> object: ...
class EmailTransportError(RuntimeError):
def __init__(self, message: str, *, transient: bool = False) -> None:
super().__init__(message)
self.transient = transient
@dataclass(frozen=True)
class EmailResult:
response_class: str
def validate_address(value: str) -> str:
if not value or len(value) > 320 or any(character in value for character in "\r\n"):
raise EmailTransportError("email address is invalid")
local, separator, domain = value.rpartition("@")
if not separator or not local or not domain or any(character.isspace() for character in value):
raise EmailTransportError("email address is invalid")
return value
def validate_recipients(values: Sequence[str]) -> list[str]:
if not values or len(values) > 20:
raise EmailTransportError("one to 20 email recipients are required")
recipients: list[str] = []
for value in values:
address = validate_address(value)
if address not in recipients:
recipients.append(address)
return recipients
def _message(
settings: NotificationEmailSettings,
event: NotificationEvent,
recipients: Sequence[str],
) -> EmailMessage:
sender = validate_address(settings.sender)
safe_recipients = validate_recipients(recipients)
message = EmailMessage()
message["From"] = formataddr(("Backup Tool", sender))
message["To"] = ", ".join(safe_recipients)
message["Subject"] = f"Backup Tool: {event.type} ({event.severity})"
message["X-Backup-Event-ID"] = event.id
# Do not put the full envelope, paths, raw errors, or credentials into mail.
message.set_content(
"Backup Tool operational event\n"
f"Event ID: {event.id}\n"
f"Type: {event.type}\n"
f"Severity: {event.severity}\n"
f"Occurred: {event.occurred_at.isoformat()}\n"
)
return message
def _deliver_sync(
settings: NotificationEmailSettings,
password: str,
event: NotificationEvent,
recipients: Sequence[str],
smtp_factory: Callable[..., SMTPClient],
) -> EmailResult:
message = _message(settings, event, recipients)
try:
with smtp_factory(settings.host, settings.port, timeout=10) as client:
client.ehlo()
context = ssl.create_default_context()
client.starttls(context=context)
client.ehlo()
client.login(settings.username, password)
client.send_message(message)
except smtplib.SMTPResponseException as error:
raise EmailTransportError(
f"smtp_{error.smtp_code}", transient=400 <= error.smtp_code < 500
) from error
except (smtplib.SMTPException, OSError) as error:
raise EmailTransportError("smtp_transport_failed", transient=True) from error
return EmailResult(response_class="smtp_2xx")
async def deliver_email(
settings: NotificationEmailSettings,
password: str,
event: NotificationEvent,
recipients: Sequence[str],
*,
smtp_factory: Callable[..., SMTPClient] | None = None,
) -> EmailResult:
"""Run blocking SMTP only in the worker thread, never in the web process."""
factory = smtp_factory or cast(Callable[..., SMTPClient], smtplib.SMTP)
return await asyncio.to_thread(_deliver_sync, settings, password, event, recipients, factory)
@@ -0,0 +1,279 @@
"""Versioned notification event catalog and transactional outbox fan-out."""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.db.models import NotificationDelivery, NotificationEvent, NotificationSubscription
from backup_tool.ids import new_uuid7
from backup_tool.security.redaction import redact
from backup_tool.security.ssrf import SSRFError, validate_webhook_url
EVENT_SCHEMA_VERSION = 1
# Live-events-only policy: every public type below has a current production emitter.
_EVENT_TYPES = (
"execution.queued",
"execution.started",
"execution.committed",
"execution.failed",
"execution.cancelled",
"execution.retry_queued",
"execution.worker_recovered",
"schedule.created",
"schedule.updated",
"schedule.deleted",
"schedule.enabled",
"schedule.disabled",
"schedule.occurrence_enqueued",
"schedule.occurrence_misfired",
"schedule.occurrence_blocked",
"backup.committed",
"backup.verification_succeeded",
"restore.queued",
"restore.committed",
"restore.failed",
"retention.tombstoned",
"notification.test_requested",
)
EVENT_CATALOG: dict[str, dict[str, Any]] = {
event_type: {
"event_schema_version": EVENT_SCHEMA_VERSION,
"severity": "error" if event_type.endswith(("failed", "blocked", "rejected")) else "info",
"payload_keys": (
"attempt",
"count",
"dry_run",
"integrity",
"message",
"outcome",
"reason_code",
"requested_mode",
"effective_mode",
"state",
),
"reserved": False,
}
for event_type in _EVENT_TYPES
}
_SAFE_RESOURCE_KEYS = frozenset(
{
"execution_id",
"job_id",
"schedule_id",
"backup_id",
"restore_id",
"repository_id",
"subscription_id",
}
)
_SAFE_PAYLOAD_KEYS = frozenset().union(
*(set(spec["payload_keys"]) for spec in EVENT_CATALOG.values())
)
class NotificationEventError(ValueError):
"""A caller attempted to produce data outside the stable public catalog."""
def _as_uuid(value: str, field: str) -> str:
try:
parsed = UUID(value)
except (TypeError, ValueError, AttributeError) as error:
raise NotificationEventError(f"{field} must be a UUID") from error
return str(parsed)
def _safe_value(value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
return redact(value)[:512]
if isinstance(value, list):
if len(value) > 32:
raise NotificationEventError("payload arrays are limited to 32 values")
return [_safe_value(item) for item in value]
if isinstance(value, Mapping):
if len(value) > 32:
raise NotificationEventError("payload objects are limited to 32 fields")
return {str(key)[:64]: _safe_value(item) for key, item in value.items()}
raise NotificationEventError("payload contains an unsupported value")
def _is_filter_match(filter_value: str, event_type: str) -> bool:
if filter_value == event_type:
return True
family, wildcard = filter_value.rsplit(".", 1) if "." in filter_value else ("", "")
return wildcard == "*" and event_type.startswith(f"{family}.")
def validate_filters(filters: Sequence[str]) -> list[str]:
if not filters:
raise NotificationEventError("at least one event filter is required")
if len(filters) > len(EVENT_CATALOG):
raise NotificationEventError("too many event filters")
output: list[str] = []
for filter_value in filters:
if not isinstance(filter_value, str) or len(filter_value) > 96:
raise NotificationEventError("invalid event filter")
if filter_value.endswith(".*"):
family = filter_value[:-2]
if not family or not any(item.startswith(f"{family}.") for item in EVENT_CATALOG):
raise NotificationEventError("unknown event filter")
elif filter_value not in EVENT_CATALOG:
raise NotificationEventError("unknown event filter")
if filter_value not in output:
output.append(filter_value)
return output
def validate_destination(channel: str, destination: Mapping[str, Any]) -> dict[str, Any]:
resource_filters = destination.get("resource_filters", {})
if not isinstance(resource_filters, Mapping):
raise NotificationEventError("resource filters are invalid")
unknown = set(resource_filters) - {"job_ids", "repository_ids", "severities"}
if unknown:
raise NotificationEventError("resource filter is unknown")
normalized_filters: dict[str, list[str]] = {}
for key in ("job_ids", "repository_ids"):
values = resource_filters.get(key)
if values is None:
continue
if not isinstance(values, list) or not values:
raise NotificationEventError("resource filter is invalid")
normalized_filters[key] = [_as_uuid(value, key) for value in values]
severities = resource_filters.get("severities")
if severities is not None:
if not isinstance(severities, list) or not severities:
raise NotificationEventError("severity filter is invalid")
if any(value not in {"info", "warning", "error", "security"} for value in severities):
raise NotificationEventError("severity filter is invalid")
normalized_filters["severities"] = list(severities)
if channel == "webhook":
url = destination.get("url")
if not isinstance(url, str) or len(url) > 2048:
raise NotificationEventError("webhook URL is required")
try:
validate_webhook_url(url)
except SSRFError as error:
raise NotificationEventError("webhook URL is invalid") from error
return {"url": url, "resource_filters": normalized_filters}
if channel == "email":
recipients = destination.get("recipients")
if not isinstance(recipients, list) or not recipients or len(recipients) > 20:
raise NotificationEventError("one to 20 email recipients are required")
safe_recipients: list[str] = []
for recipient in recipients:
if not isinstance(recipient, str) or any(char in recipient for char in "\r\n"):
raise NotificationEventError("invalid email recipient")
if "@" not in recipient or len(recipient) > 320:
raise NotificationEventError("invalid email recipient")
if recipient not in safe_recipients:
safe_recipients.append(recipient)
return {"recipients": safe_recipients, "resource_filters": normalized_filters}
raise NotificationEventError("unsupported notification channel")
def _matches(subscription: NotificationSubscription, event: dict[str, Any]) -> bool:
if subscription.state != "active":
return False
if not any(_is_filter_match(item, str(event["type"])) for item in subscription.event_filters):
return False
filters = subscription.destination_config.get("resource_filters", {})
if not isinstance(filters, Mapping):
return False
resources = event["resource"]
for key in ("job_ids", "repository_ids"):
selected = filters.get(key)
resource_key = key[:-1]
if selected is not None and resources.get(resource_key) not in selected:
return False
severities = filters.get("severities")
return severities is None or event["severity"] in severities
async def emit_event(
db: AsyncSession,
event_type: str,
*,
correlation_id: str,
resource: Mapping[str, str] | None = None,
payload: Mapping[str, Any] | None = None,
severity: str | None = None,
deduplication_key: str | None = None,
occurred_at: datetime | None = None,
only_subscription_id: str | None = None,
) -> NotificationEvent:
"""Append an immutable event and matching deliveries; intentionally never commits."""
if event_type not in EVENT_CATALOG:
raise NotificationEventError("unknown operational event type")
correlation_id = _as_uuid(correlation_id, "correlation_id")
resource = resource or {}
if set(resource) - _SAFE_RESOURCE_KEYS:
raise NotificationEventError("unknown resource reference")
safe_resource = {key: _as_uuid(value, key) for key, value in resource.items()}
payload = payload or {}
if set(payload) - _SAFE_PAYLOAD_KEYS:
raise NotificationEventError("payload key is not allowlisted")
safe_payload = {key: _safe_value(value) for key, value in payload.items()}
event_severity = severity or str(EVENT_CATALOG[event_type]["severity"])
if event_severity not in {"info", "warning", "error", "security"}:
raise NotificationEventError("invalid severity")
if deduplication_key is not None and (not deduplication_key or len(deduplication_key) > 255):
raise NotificationEventError("invalid deduplication key")
if deduplication_key is not None:
existing = await db.scalar(
select(NotificationEvent).where(
NotificationEvent.deduplication_key == deduplication_key
)
)
if existing is not None:
return existing
event_id = str(new_uuid7())
timestamp = (occurred_at or datetime.now(UTC)).astimezone(UTC)
envelope = {
"event_schema_version": EVENT_SCHEMA_VERSION,
"id": event_id,
"type": event_type,
"occurred_at": timestamp.isoformat(),
"correlation_id": correlation_id,
"severity": event_severity,
"resource": safe_resource,
"payload": safe_payload,
}
event = NotificationEvent(
id=event_id,
type=event_type,
schema_version=EVENT_SCHEMA_VERSION,
occurred_at=timestamp,
correlation_id=correlation_id,
severity=event_severity,
resource_refs=safe_resource,
payload=safe_payload,
canonical_envelope=json.dumps(envelope, sort_keys=True, separators=(",", ":")),
deduplication_key=deduplication_key,
)
db.add(event)
await db.flush()
active_subscriptions = select(NotificationSubscription).where(
NotificationSubscription.state == "active"
)
subscriptions = list((await db.scalars(active_subscriptions)).all())
for subscription in subscriptions:
selected_for_test = only_subscription_id == subscription.id
if selected_for_test or (only_subscription_id is None and _matches(subscription, envelope)):
db.add(
NotificationDelivery(
event_id=event.id,
subscription_id=subscription.id,
due_at=timestamp,
)
)
await db.flush()
return event
@@ -0,0 +1,33 @@
"""Deterministic bounded retry classification for notification delivery."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RetryDecision:
retry: bool
response_class: str
reason: str
def retry_delay(attempt: int, cap_seconds: int) -> int:
"""Bounded exponential delay; no jitter keeps durable tests/restarts deterministic."""
exponent = max(0, attempt - 1)
return min(cap_seconds, 1 << exponent)
def webhook_decision(status_code: int) -> RetryDecision:
if 200 <= status_code < 300:
return RetryDecision(False, "http_2xx", "delivered")
if status_code in {408, 425, 429} or status_code >= 500:
return RetryDecision(True, f"http_{status_code}", "http_transient")
if 300 <= status_code < 400:
return RetryDecision(False, f"http_{status_code}", "redirect_rejected")
return RetryDecision(False, f"http_{status_code}", "http_permanent")
def transport_decision(transient: bool, response_class: str) -> RetryDecision:
reason = "transport_transient" if transient else "transport_failed"
return RetryDecision(transient, response_class, reason)
@@ -0,0 +1,210 @@
"""Canonical, dual-key HMAC webhook requests on a DNS-pinned HTTPX transport."""
from __future__ import annotations
import asyncio
import hmac
import ssl
from collections.abc import Sequence
from dataclasses import dataclass
from hashlib import sha256
import httpx
from backup_tool.security.ssrf import (
ResolvedWebhookTarget,
Resolver,
SSRFError,
resolve_webhook_target,
verify_connected_peer,
)
SIGNATURE_VERSION = "v1"
class WebhookTransportError(RuntimeError):
def __init__(self, message: str, *, transient: bool = False) -> None:
super().__init__(message)
self.transient = transient
@dataclass(frozen=True)
class SigningMaterial:
key_id: str
version: int
secret: str
@dataclass(frozen=True)
class WebhookResult:
status_code: int
response_bytes: int
def canonical_signing_input(timestamp: str, body: bytes) -> bytes:
return SIGNATURE_VERSION.encode() + b"." + timestamp.encode("ascii") + b"." + body
def signatures(timestamp: str, body: bytes, keys: Sequence[SigningMaterial]) -> list[str]:
signing_input = canonical_signing_input(timestamp, body)
return [
f"{SIGNATURE_VERSION};key_id={key.key_id};key_version={key.version};sha256="
f"{hmac.new(key.secret.encode(), signing_input, sha256).hexdigest()}"
for key in keys
]
class PinnedWebhookTransport(httpx.AsyncBaseTransport):
"""HTTPX transport that never lets a post-validation DNS lookup choose a peer."""
def __init__(
self,
*,
target: ResolvedWebhookTarget,
connect_timeout: float,
read_timeout: float,
max_response_bytes: int,
) -> None:
self._target = target
self._connect_timeout = connect_timeout
self._read_timeout = read_timeout
self._max_response_bytes = max_response_bytes
async def _connect(
self, target: ResolvedWebhookTarget
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
hostname = target.url.hostname
if hostname is None: # guarded by resolve_webhook_target
raise WebhookTransportError("webhook hostname is unavailable")
context = ssl.create_default_context() if target.url.scheme == "https" else None
last_error: OSError | None = None
for address in target.addresses:
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(
address,
target.port,
ssl=context,
server_hostname=hostname if context is not None else None,
),
timeout=self._connect_timeout,
)
verify_connected_peer(writer.get_extra_info("peername"), target.addresses)
return reader, writer
except (TimeoutError, OSError, ssl.SSLError, SSRFError) as error:
last_error = error if isinstance(error, OSError) else None
raise WebhookTransportError("webhook connection failed", transient=True) from last_error
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
target = self._target
if str(request.url) != target.url.geturl():
raise WebhookTransportError("webhook target changed")
body = await request.aread()
if len(body) > 65_536:
raise WebhookTransportError("webhook body is too large")
reader, writer = await self._connect(target)
try:
raw_path = target.url.path or "/"
if target.url.query:
raw_path += "?" + target.url.query
headers = [(key, value) for key, value in request.headers.multi_items()]
header_names = {key.lower() for key, _ in headers}
if "host" not in header_names:
host = target.url.hostname or ""
headers.append(("Host", host))
if "content-length" not in header_names:
headers.append(("Content-Length", str(len(body))))
headers.append(("Connection", "close"))
serialized = [f"{request.method} {raw_path} HTTP/1.1\r\n".encode()]
serialized.extend(f"{key}: {value}\r\n".encode("ascii") for key, value in headers)
writer.write(b"".join(serialized) + b"\r\n" + body)
await asyncio.wait_for(writer.drain(), timeout=self._read_timeout)
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=self._read_timeout)
if len(head) > 16_384:
raise WebhookTransportError("webhook response headers are too large")
lines = head.decode("iso-8859-1").split("\r\n")
try:
_protocol, code, _reason = lines[0].split(" ", 2)
status_code = int(code)
except (IndexError, ValueError) as error:
raise WebhookTransportError("webhook response is malformed") from error
response_headers: list[tuple[str, str]] = []
for line in lines[1:]:
if not line:
continue
key, separator, value = line.partition(":")
if not separator:
raise WebhookTransportError("webhook response headers are malformed")
response_headers.append((key.strip(), value.strip()))
content = await asyncio.wait_for(
reader.read(self._max_response_bytes + 1), timeout=self._read_timeout
)
if len(content) > self._max_response_bytes:
raise WebhookTransportError("webhook response is too large")
return httpx.Response(
status_code,
headers=response_headers,
content=content,
request=request,
)
except (TimeoutError, OSError, asyncio.IncompleteReadError) as error:
raise WebhookTransportError("webhook request failed", transient=True) from error
finally:
writer.close()
with __import__("contextlib").suppress(OSError):
await writer.wait_closed()
def webhook_headers(
event_id: str,
event_type: str,
timestamp: str,
body: bytes,
keys: Sequence[SigningMaterial],
) -> list[tuple[str, str]]:
headers: list[tuple[str, str]] = [
("Content-Type", "application/json"),
("X-Backup-Event-ID", event_id),
("X-Backup-Event-Type", event_type),
("X-Backup-Signature-Version", SIGNATURE_VERSION),
("X-Backup-Timestamp", timestamp),
]
headers.extend(("X-Backup-Signature", value) for value in signatures(timestamp, body, keys))
return headers
async def deliver_webhook(
url: str,
body: bytes,
*,
event_id: str,
event_type: str,
timestamp: str,
keys: Sequence[SigningMaterial],
resolver: Resolver,
connect_timeout: float,
read_timeout: float,
max_response_bytes: int,
) -> WebhookResult:
if not keys:
raise WebhookTransportError("webhook subscription has no active signing key")
try:
# Resolve immediately before this individual attempt. The resulting
# addresses are passed to the transport, so it cannot rebind at connect.
target = await resolve_webhook_target(url, resolver)
except SSRFError as error:
raise WebhookTransportError("webhook_target_rejected") from error
transport = PinnedWebhookTransport(
target=target,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
max_response_bytes=max_response_bytes,
)
headers = webhook_headers(event_id, event_type, timestamp, body, keys)
async with httpx.AsyncClient(
transport=transport, follow_redirects=False, trust_env=False
) as client:
response = await client.post(url, content=body, headers=headers)
if 300 <= response.status_code < 400:
raise WebhookTransportError("redirect_rejected")
return WebhookResult(status_code=response.status_code, response_bytes=len(response.content))
@@ -0,0 +1 @@
"""Operational logging, metrics, and readiness primitives."""
@@ -0,0 +1,39 @@
"""Readiness checks shared by HTTP and background process roles."""
from __future__ import annotations
import os
from backup_tool.cli import build_alembic_config
from backup_tool.config import Settings
from backup_tool.db.engine import assert_schema_current, create_engine
class ReadinessError(RuntimeError):
"""A dependency needed by the selected role is unavailable."""
def _require_access(path: object, mode: int) -> None:
try:
candidate = path if isinstance(path, str) else str(path)
if not os.path.isdir(candidate) or not os.access(candidate, mode):
raise ReadinessError("required storage is unavailable")
except OSError as error:
raise ReadinessError("required storage is unavailable") from error
async def check_role_readiness(settings: Settings, role: str) -> None:
engine = create_engine(settings)
try:
await assert_schema_current(engine, build_alembic_config(settings))
except Exception as error:
raise ReadinessError("metadata is unavailable") from error
finally:
await engine.dispose()
if role == "scheduler":
return
_require_access(settings.data_dir, os.R_OK | os.W_OK | os.X_OK)
for root in settings.repository_roots + settings.restore_roots:
_require_access(root, os.R_OK | os.W_OK | os.X_OK)
for root in settings.local_source_roots:
_require_access(root, os.R_OK | os.X_OK)
@@ -0,0 +1,39 @@
"""JSON logging that keeps operational context machine-readable and secret-free."""
from __future__ import annotations
import json
import logging
import sys
from datetime import UTC, datetime
from typing import Any
_STANDARD_RECORD_KEYS = frozenset(logging.makeLogRecord({}).__dict__)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"event": record.getMessage(),
"level": record.levelname.lower(),
"logger": record.name,
"timestamp": datetime.now(UTC).isoformat(),
}
for key, value in record.__dict__.items():
if key not in _STANDARD_RECORD_KEYS and key not in {"message", "asctime"}:
payload[key] = value
return json.dumps(payload, default=str, separators=(",", ":"), sort_keys=True)
def configure_logging(role: str, level: str) -> None:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
logging.getLogger("backup_tool").info("role_started", extra={"role": role})
def log_event(name: str, **fields: object) -> None:
logging.getLogger("backup_tool").info(name, extra=fields)
@@ -0,0 +1,102 @@
"""Small dependency-free Prometheus exposition for the single-node appliance."""
from __future__ import annotations
import os
import threading
from collections import defaultdict
from collections.abc import Iterable
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.db.models import Backup, Execution, Repository, Schedule
from backup_tool.execution import ACTIVE_STATES
class Metrics:
def __init__(self) -> None:
self._lock = threading.Lock()
self._requests: dict[tuple[str, str, int], int] = defaultdict(int)
self._durations: dict[tuple[str, str], tuple[int, float]] = {}
def observe_request(self, method: str, path: str, status: int, duration_seconds: float) -> None:
route = path if path in {"/livez", "/readyz", "/metrics"} else "/api"
with self._lock:
self._requests[(method, route, status)] += 1
count, total = self._durations.get((method, route), (0, 0.0))
self._durations[(method, route)] = (count + 1, total + duration_seconds)
def render(self, operational: Iterable[tuple[str, float]]) -> str:
lines = [
"# HELP backup_tool_http_requests_total HTTP requests handled by the web role.",
"# TYPE backup_tool_http_requests_total counter",
]
with self._lock:
for (method, path, status), value in sorted(self._requests.items()):
labels = f'method="{method}",path="{path}",status="{status}"'
lines.append(f"backup_tool_http_requests_total{{{labels}}} {value}")
lines.extend(
[
"# HELP backup_tool_http_request_duration_seconds HTTP request duration.",
"# TYPE backup_tool_http_request_duration_seconds summary",
]
)
for (method, path), (count, total) in sorted(self._durations.items()):
labels = f'method="{method}",path="{path}"'
lines.append(f"backup_tool_http_request_duration_seconds_count{{{labels}}} {count}")
lines.append(
f"backup_tool_http_request_duration_seconds_sum{{{labels}}} {total:.6f}"
)
lines.extend(f"{name} {value}" for name, value in operational)
return "\n".join(lines) + "\n"
async def collect_operational_metrics(
settings: Settings, db: AsyncSession
) -> list[tuple[str, float]]:
now = datetime.now(UTC)
active = await db.scalar(
select(func.count()).select_from(Execution).where(Execution.state.in_(ACTIVE_STATES))
)
stale = await db.scalar(
select(func.count())
.select_from(Execution)
.where(Execution.lease_expires_at.is_not(None), Execution.lease_expires_at < now)
)
failed = await db.scalar(
select(func.count()).select_from(Execution).where(Execution.state == "failed")
)
corrupt = await db.scalar(
select(func.count()).select_from(Backup).where(Backup.integrity == "corrupt")
)
schedule_lag = await db.scalar(
select(func.min(Schedule.next_nominal_at)).where(
Schedule.enabled, Schedule.next_nominal_at.is_not(None)
)
)
values = [
("backup_tool_active_executions", active or 0),
("backup_tool_stale_execution_leases", stale or 0),
("backup_tool_failed_executions", failed or 0),
("backup_tool_corrupt_backups", corrupt or 0),
(
"backup_tool_schedule_lag_seconds",
max(0.0, (now - schedule_lag).total_seconds()) if schedule_lag is not None else 0.0,
),
]
roots = list(settings.repository_roots) + list(settings.restore_roots)
for index, root in enumerate(roots):
try:
stats = os.statvfs(root)
except OSError:
continue
name = f'backup_tool_filesystem_free_bytes{{root="{index}"}}'
values.append((name, stats.f_bavail * stats.f_frsize))
unavailable = await db.scalar(
select(func.count()).select_from(Repository).where(Repository.state == "unavailable")
)
values.append(("backup_tool_unavailable_repositories", unavailable or 0))
return values
View File
+512
View File
@@ -0,0 +1,512 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
import stat
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from sqlalchemy.ext.asyncio import AsyncSession
from backup_tool.config import Settings
from backup_tool.ids import new_uuid7
from backup_tool.security.repository_crypto import RepositoryKeyError, create_data_key
class RepositoryError(ValueError):
pass
@dataclass(frozen=True)
class InitializedRepository:
root: Path
repository_id: str
format_version: int = 1
compression: str = "none"
encryption: str = "none"
signing_key_id: str = ""
signing_public_key: str = ""
signing_key_path: Path | None = None
data_key_id: str | None = None
data_key_path: Path | None = None
def blob_digest(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _contained(root: Path, relative_path: str) -> Path:
relative = Path(relative_path)
if not relative_path or relative.is_absolute():
raise RepositoryError("repository path must be a non-empty relative child")
lexical = root
for part in relative.parts:
lexical /= part
if lexical.is_symlink():
raise RepositoryError("repository path contains a symlink")
candidate = lexical.resolve()
resolved_root = root.resolve()
if candidate == resolved_root:
raise RepositoryError("repository path must be a non-empty relative child")
if resolved_root not in candidate.parents:
raise RepositoryError("repository path escapes configured roots")
return candidate
def _canonical_payload(compression: str, encryption: str) -> dict[str, object]:
if compression != "none" or encryption not in {"none", "aes-256-gcm"}:
raise RepositoryError("requested repository policy is unavailable")
return {
"repository_id": str(new_uuid7()),
"format_version": 1,
"digest_algorithm": "sha256",
"compression": compression,
"encryption": {"mode": encryption, "key_id": None},
"created_at": datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z"),
}
def _canonical_json(payload: dict[str, object]) -> str:
return json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n"
def replace_active_data_key(root: Path, expected_key_id: str, new_key_id: str) -> None:
"""Durably replace the active, non-secret epoch reference in repository metadata."""
metadata = root / "repository.json"
try:
payload = json.loads(metadata.read_text(encoding="utf-8"))
encryption = payload.get("encryption") if isinstance(payload, dict) else None
if (
not isinstance(encryption, dict)
or encryption.get("mode") != "aes-256-gcm"
or encryption.get("key_id") != expected_key_id
):
raise ValueError
except (OSError, ValueError, json.JSONDecodeError) as error:
raise RepositoryError("repository metadata is invalid") from error
encryption["key_id"] = new_key_id
staging = root / f".repository.json.{os.urandom(8).hex()}.tmp"
try:
descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(_canonical_json(payload))
handle.flush()
os.fsync(handle.fileno())
os.replace(staging, metadata)
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository metadata cannot be updated") from error
finally:
staging.unlink(missing_ok=True)
_ROTATION_JOURNAL = ".key-rotation.json"
def begin_key_rotation(
root: Path,
repository_database_id: str,
repository_id: str,
old_key_id: str,
new_key_id: str,
) -> None:
"""Persist an intent record before changing durable key-epoch state."""
if not all((repository_database_id, repository_id, old_key_id, new_key_id)):
raise RepositoryError("repository rotation journal is invalid")
journal = root / _ROTATION_JOURNAL
payload: dict[str, object] = {
"new_key_id": new_key_id,
"old_key_id": old_key_id,
"repository_database_id": repository_database_id,
"repository_id": repository_id,
"version": 1,
}
try:
descriptor = os.open(journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(_canonical_json(payload))
handle.flush()
os.fsync(handle.fileno())
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository rotation is already in progress") from error
def read_key_rotation(root: Path) -> dict[str, str] | None:
journal = root / _ROTATION_JOURNAL
if not journal.exists():
return None
try:
if journal.is_symlink() or stat.S_IMODE(journal.stat().st_mode) != 0o600:
raise ValueError
payload = json.loads(journal.read_text(encoding="utf-8"))
expected = {
"new_key_id",
"old_key_id",
"repository_database_id",
"repository_id",
"version",
}
if (
not isinstance(payload, dict)
or set(payload) != expected
or payload.get("version") != 1
or not all(
isinstance(payload[key], str) and payload[key] for key in expected - {"version"}
)
):
raise ValueError
return {key: payload[key] for key in expected - {"version"}}
except (OSError, ValueError, json.JSONDecodeError) as error:
raise RepositoryError("repository rotation journal is invalid") from error
def finish_key_rotation(root: Path) -> None:
journal = root / _ROTATION_JOURNAL
try:
journal.unlink()
descriptor = os.open(root, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
except OSError as error:
raise RepositoryError("repository rotation journal cannot be cleared") from error
async def reconcile_key_rotations(settings: Settings, db: AsyncSession) -> int:
"""Converge a journaled key rotation after a process crash.
A rotation whose DB transaction did not commit remains on the old active
epoch. A committed transaction deterministically advances repository.json.
"""
from sqlalchemy import select
from backup_tool.db.models import Repository, RepositoryDataKeyEpoch
from backup_tool.security.repository_crypto import load_data_key
repositories = list((await db.scalars(select(Repository))).all())
reconciled = 0
for repository in repositories:
if repository.encryption != "aes-256-gcm":
continue
root = Path(repository.root)
journal = read_key_rotation(root)
if journal is None:
continue
inspected = inspect_repository(settings, root)
if (
journal["repository_database_id"] != repository.id
or journal["repository_id"] != inspected.repository_id
or repository.active_data_key_id is None
):
raise RepositoryError("repository rotation journal does not match metadata")
epochs = list(
(
await db.scalars(
select(RepositoryDataKeyEpoch).where(
RepositoryDataKeyEpoch.repository_id == repository.id
)
)
).all()
)
active = [epoch for epoch in epochs if epoch.state == "active"]
old_key_id = journal["old_key_id"]
new_key_id = journal["new_key_id"]
if len(active) != 1:
raise RepositoryError("repository rotation epochs are invalid")
if repository.active_data_key_id == old_key_id and active[0].key_id == old_key_id:
if inspected.data_key_id != old_key_id:
raise RepositoryError("repository rotation metadata is invalid")
new_path = (
settings.data_dir
/ "repository-data-keys"
/ (f"{inspected.repository_id}.{new_key_id}.key")
)
# Clear the durable intent before deleting an unreferenced key. A
# crash afterward leaves only an orphaned key, not a journal whose
# retry depends on a key that no longer exists.
if new_path.exists() or new_path.is_symlink():
try:
load_data_key(settings, inspected.repository_id, new_key_id)
except RepositoryKeyError as error:
raise RepositoryError("repository rotation key is unavailable") from error
finish_key_rotation(inspected.root)
if new_path.exists() or new_path.is_symlink():
# The journal is already gone, so a cleanup failure only leaves
# an unreferenced key and must not disable the old-active epoch.
with suppress(OSError):
new_path.unlink()
descriptor = os.open(new_path.parent, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
reconciled += 1
continue
if repository.active_data_key_id == new_key_id and active[0].key_id == new_key_id:
try:
load_data_key(settings, inspected.repository_id, new_key_id)
except RepositoryKeyError as error:
raise RepositoryError("repository rotation key is unavailable") from error
if inspected.data_key_id == old_key_id:
replace_active_data_key(inspected.root, old_key_id, new_key_id)
elif inspected.data_key_id != new_key_id:
raise RepositoryError("repository rotation metadata is invalid")
finish_key_rotation(inspected.root)
reconciled += 1
continue
raise RepositoryError("repository rotation state is invalid")
return reconciled
def assert_capacity(settings: Settings, root: Path) -> None:
capacity_root = root
while not capacity_root.exists():
parent = capacity_root.parent
if parent == capacity_root:
raise RepositoryError("repository root is unreadable")
capacity_root = parent
usage = shutil.disk_usage(capacity_root)
free_percent = usage.free * 100 / usage.total
if usage.free < settings.min_free_bytes or free_percent < settings.min_free_percent:
raise RepositoryError("repository root does not meet minimum free capacity")
def _signing_key_directory(settings: Settings) -> Path:
directory = settings.data_dir / "repository-keys"
directory.mkdir(mode=0o700, exist_ok=True)
if directory.is_symlink() or not directory.is_dir():
raise RepositoryError("repository signing key directory is unsafe")
if stat.S_IMODE(directory.stat().st_mode) != 0o700:
raise RepositoryError("repository signing key directory permissions must be 0700")
return directory
def _write_private_key_staging(directory: Path, repository_id: str, private_key: bytes) -> Path:
staging = directory / f".{repository_id}.{os.urandom(8).hex()}.tmp"
descriptor = os.open(staging, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(private_key)
handle.flush()
os.fsync(handle.fileno())
return staging
def initialize(
settings: Settings, relative_path: str, compression: str, encryption: str
) -> InitializedRepository:
payload = _canonical_payload(compression, encryption)
repository_id = str(payload["repository_id"])
data_key_id: str | None = None
data_key_path: Path | None = None
if encryption == "aes-256-gcm":
try:
data_key_id, data_key_path = create_data_key(settings, repository_id)
except RepositoryKeyError as error:
raise RepositoryError("repository data key is unavailable") from error
payload["encryption"] = {"mode": encryption, "key_id": data_key_id}
private_key = Ed25519PrivateKey.generate()
private_bytes = private_key.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
)
public_bytes = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
signing_key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}"
key_directory = _signing_key_directory(settings)
key_path = key_directory / f"{repository_id}.ed25519"
key_staging = _write_private_key_staging(key_directory, repository_id, private_bytes)
root = _contained(settings.repository_roots[0], relative_path)
assert_capacity(settings, root.parent)
if root.exists():
key_staging.unlink(missing_ok=True)
raise RepositoryError("repository path already exists")
staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}")
try:
staging.mkdir(parents=True)
(staging / "blobs" / "sha256").mkdir(parents=True)
(staging / "manifests").mkdir()
metadata = staging / "repository.json"
metadata.write_text(_canonical_json(payload), encoding="utf-8")
with metadata.open("rb") as handle:
os.fsync(handle.fileno())
os.replace(staging, root)
os.replace(key_staging, key_path)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
shutil.rmtree(root, ignore_errors=True)
key_staging.unlink(missing_ok=True)
key_path.unlink(missing_ok=True)
if data_key_path is not None:
data_key_path.unlink(missing_ok=True)
raise
return InitializedRepository(
root=root,
repository_id=repository_id,
compression=compression,
encryption=encryption,
signing_key_id=signing_key_id,
signing_public_key=public_bytes.hex(),
signing_key_path=key_path,
data_key_id=data_key_id,
data_key_path=data_key_path,
)
def remove_repository(
root: Path,
signing_key_path: Path | None = None,
data_key_path: Path | None = None,
) -> None:
try: # noqa: SIM105 - cleanup must not race a concurrent remover
shutil.rmtree(root)
except FileNotFoundError:
pass
for key_path in (signing_key_path, data_key_path):
if key_path is None:
continue
try: # noqa: SIM105 - cleanup must not race a concurrent remover
key_path.unlink()
except FileNotFoundError:
pass
def install_signing_key(
settings: Settings,
repository_id: str,
expected_key_id: str,
expected_public_key: str,
private_bytes: bytes,
) -> Path:
"""Install a recovered signing key once after checking its public trust anchor."""
if len(private_bytes) != 32 or "/" in repository_id:
raise RepositoryError("repository signing key is invalid")
try:
private_key = Ed25519PrivateKey.from_private_bytes(private_bytes)
public_bytes = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
except ValueError as error:
raise RepositoryError("repository signing key is invalid") from error
key_id = f"ed25519-{hashlib.sha256(public_bytes).hexdigest()[:32]}"
if key_id != expected_key_id or public_bytes.hex() != expected_public_key:
raise RepositoryError("repository signing key is invalid")
path = _signing_key_directory(settings) / f"{repository_id}.ed25519"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(private_bytes)
handle.flush()
os.fsync(handle.fileno())
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
path.unlink(missing_ok=True)
raise RepositoryError("repository signing key is unsafe")
except OSError as error:
raise RepositoryError("repository signing key cannot be installed") from error
return path
def load_signing_key(
settings: Settings,
repository_id: str,
expected_key_id: str,
expected_public_key: str,
) -> Ed25519PrivateKey:
key_path = _signing_key_directory(settings) / f"{repository_id}.ed25519"
try:
if key_path.is_symlink() or stat.S_IMODE(key_path.stat().st_mode) != 0o600:
raise RepositoryError("repository signing key is unsafe")
private_key = Ed25519PrivateKey.from_private_bytes(key_path.read_bytes())
except (OSError, ValueError) as error:
raise RepositoryError("repository signing key is unreadable") from error
public_key = private_key.public_key().public_bytes(
serialization.Encoding.Raw,
serialization.PublicFormat.Raw,
)
key_id = f"ed25519-{hashlib.sha256(public_key).hexdigest()[:32]}"
if key_id != expected_key_id or public_key.hex() != expected_public_key:
raise RepositoryError("repository signing key does not match its trust anchor")
return private_key
def inspect_repository(settings: Settings, root: Path) -> InitializedRepository:
try:
resolved_root = root.resolve(strict=True)
except OSError as error:
raise RepositoryError("repository root is unreadable") from error
if root.is_symlink() or not any(
resolved_root != allowed.resolve() and allowed.resolve() in resolved_root.parents
for allowed in settings.repository_roots
):
raise RepositoryError("repository root escapes configured roots")
metadata = resolved_root / "repository.json"
try:
raw = metadata.read_text(encoding="utf-8")
payload = json.loads(raw)
except (OSError, json.JSONDecodeError) as error:
raise RepositoryError("repository metadata is unreadable") from error
if not isinstance(payload, dict):
raise RepositoryError("repository metadata is invalid")
required = {
"repository_id",
"format_version",
"digest_algorithm",
"compression",
"encryption",
"created_at",
}
if (
set(payload) != required
or payload.get("format_version") != 1
or payload.get("digest_algorithm") != "sha256"
):
raise RepositoryError("repository metadata is not canonical")
encryption = payload.get("encryption")
if payload.get("compression") != "none" or not isinstance(encryption, dict):
raise RepositoryError("repository metadata is invalid")
mode = encryption.get("mode")
key_id = encryption.get("key_id")
valid_policy = (mode == "none" and key_id is None) or (
mode == "aes-256-gcm" and isinstance(key_id, str) and bool(key_id)
)
if not valid_policy:
raise RepositoryError("repository metadata is invalid")
try:
from uuid import UUID
UUID(str(payload["repository_id"]))
created_at = str(payload["created_at"])
if not created_at.endswith("Z"):
raise ValueError
datetime.fromisoformat(created_at[:-1] + "+00:00")
except (ValueError, TypeError) as error:
raise RepositoryError("repository metadata is invalid") from error
return InitializedRepository(
root=resolved_root,
repository_id=str(payload["repository_id"]),
compression="none",
encryption=str(encryption["mode"]),
data_key_id=key_id if isinstance(key_id, str) else None,
)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Protocol
class RetentionError(ValueError):
pass
class BackupLike(Protocol):
id: str
created_at: datetime
pinned: bool
tombstoned_at: datetime | None
@dataclass(frozen=True)
class RetentionPolicy:
keep_last: int = 1
keep_days: int = 0
keep_daily: int = 0
keep_weekly: int = 0
keep_monthly: int = 0
@classmethod
def from_dict(cls, value: dict[str, object]) -> RetentionPolicy:
allowed = {"keep_last", "keep_days", "keep_daily", "keep_weekly", "keep_monthly"}
if set(value) - allowed:
raise RetentionError("retention policy contains unknown keys")
kwargs: dict[str, int] = {}
for key in allowed:
raw = value.get(key, 0 if key != "keep_last" else 1)
if not isinstance(raw, int) or isinstance(raw, bool) or raw < 0:
raise RetentionError("retention values must be non-negative integers")
kwargs[key] = raw
return cls(**kwargs)
def retained_ids(
backups: Iterable[BackupLike], policy: RetentionPolicy, now: datetime | None = None
) -> set[str]:
"""Return union retention set; the newest non-tombstoned backup is always protected."""
reference = (now or datetime.now(UTC)).astimezone(UTC)
items = sorted(
(item for item in backups if item.tombstoned_at is None),
key=lambda item: item.created_at.astimezone(UTC),
reverse=True,
)
if not items:
return set()
kept = {items[0].id}
kept.update(item.id for item in items[: policy.keep_last])
kept.update(item.id for item in items if item.pinned)
if policy.keep_days:
cutoff = reference - timedelta(days=policy.keep_days)
kept.update(item.id for item in items if item.created_at.astimezone(UTC) >= cutoff)
for count, key in (
(policy.keep_daily, lambda stamp: stamp.date()),
(policy.keep_weekly, lambda stamp: stamp.isocalendar()[:2]),
(policy.keep_monthly, lambda stamp: (stamp.year, stamp.month)),
):
buckets: set[object] = set()
for item in items:
stamp = item.created_at.astimezone(UTC)
bucket = key(stamp)
if len(buckets) >= count and bucket not in buckets:
continue
buckets.add(bucket)
if bucket in buckets:
kept.add(item.id)
return kept
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import asyncio
import contextlib
import signal
from datetime import UTC, datetime
from typing import cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.triggers.cron import CronTrigger # type: ignore[import-untyped]
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Schedule
from backup_tool.execution import EnqueueError, enqueue
from backup_tool.notifications.events import emit_event
from backup_tool.observability.logging import configure_logging, log_event
class ScheduleError(ValueError):
pass
def next_nominal(cron: str, timezone: str, after: datetime | None = None) -> datetime:
if len(cron.split()) != 5:
raise ScheduleError("cron must contain exactly five fields")
try:
zone = ZoneInfo(timezone)
trigger = CronTrigger.from_crontab(cron, timezone=zone)
except (ValueError, ZoneInfoNotFoundError) as error:
raise ScheduleError("cron or timezone is invalid") from error
reference = (after or datetime.now(UTC)).astimezone(zone)
next_run = trigger.get_next_fire_time(None, reference)
if next_run is None:
raise ScheduleError("cron has no future occurrence")
return cast(datetime, next_run.astimezone(UTC))
class SchedulerService:
"""Dedicated scheduler role using the same transactional enqueue service."""
def __init__(self, settings: Settings) -> None:
self.engine = create_engine(settings)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
self._stopping = asyncio.Event()
async def run_once(self) -> int:
async with self.sessions() as db:
return await deliver_due(db)
async def run(self) -> None:
while not self._stopping.is_set():
await self.run_once()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stopping.wait(), timeout=0.25)
await self.engine.dispose()
def stop(self) -> None:
log_event("role_stopping", role="scheduler")
self._stopping.set()
def run_scheduler(settings: Settings) -> int:
configure_logging("scheduler", settings.log_level)
service = SchedulerService(settings)
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, service.stop)
try:
loop.run_until_complete(service.run())
finally:
loop.close()
log_event("role_stopped", role="scheduler")
return 0
async def deliver_due(db: AsyncSession, now: datetime | None = None) -> int:
current = now or datetime.now(UTC)
schedules = list(
(
await db.scalars(
select(Schedule).where(
Schedule.enabled,
Schedule.next_nominal_at.is_not(None),
Schedule.next_nominal_at <= current,
)
)
).all()
)
delivered = 0
for schedule in schedules:
nominal = schedule.next_nominal_at
if nominal is None:
continue
schedule.next_nominal_at = next_nominal(schedule.cron, schedule.timezone, nominal)
if (current - nominal).total_seconds() > schedule.misfire_grace_seconds:
schedule.last_enqueue_outcome = "misfire"
await emit_event(
db,
"schedule.occurrence_misfired",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"outcome": "misfire"},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:misfire",
)
continue
try:
await enqueue(
db,
schedule.job_id,
"schedule",
schedule_id=schedule.id,
nominal_run_at=nominal,
)
except EnqueueError as error:
schedule.last_enqueue_outcome = error.code
await emit_event(
db,
"schedule.occurrence_blocked",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"reason_code": error.code},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:blocked",
)
else:
schedule.last_enqueue_outcome = "enqueued"
await emit_event(
db,
"schedule.occurrence_enqueued",
correlation_id=schedule.id,
resource={"schedule_id": schedule.id, "job_id": schedule.job_id},
payload={"outcome": "enqueued"},
deduplication_key=f"schedule:{schedule.id}:nominal:{nominal.isoformat()}:enqueued",
)
delivered += 1
await db.commit()
return delivered
@@ -0,0 +1 @@
"""Security primitives and authentication services."""
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
_PASSWORDS = PasswordHasher()
def hash_password(password: str) -> str:
return _PASSWORDS.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return _PASSWORDS.verify(password_hash, password)
except VerifyMismatchError:
return False
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def new_token() -> str:
return secrets.token_urlsafe(32)
def new_csrf_token() -> str:
return secrets.token_urlsafe(32)
def _session_key(master_key_file: Path) -> bytes:
return hashlib.sha256(b"backup-tool-session\0" + master_key_file.read_bytes()).digest()
def sign_session(
user_id: str,
csrf: str,
master_key_file: Path,
*,
expires_at: datetime | None = None,
session_id: str | None = None,
) -> str:
issued_at = datetime.now(UTC)
expires_at = expires_at or issued_at.replace(microsecond=0) + timedelta(hours=8)
payload = json.dumps(
{
"sub": user_id,
"csrf": csrf,
"iat": issued_at.isoformat(),
"exp": expires_at.astimezone(UTC).isoformat(),
"sid": session_id or secrets.token_urlsafe(24),
},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
signature = hmac.new(
_session_key(master_key_file), encoded.encode(), hashlib.sha256
).hexdigest()
return f"{encoded}.{signature}"
def verify_session(value: str, master_key_file: Path) -> dict[str, Any] | None:
try:
encoded, supplied = value.rsplit(".", 1)
expected = hmac.new(
_session_key(master_key_file), encoded.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(supplied, expected):
return None
padded = encoded + "=" * (-len(encoded) % 4)
decoded = json.loads(base64.urlsafe_b64decode(padded))
if (
not isinstance(decoded, dict)
or not isinstance(decoded.get("sub"), str)
or not isinstance(decoded.get("sid"), str)
or not isinstance(decoded.get("exp"), str)
or datetime.fromisoformat(decoded["exp"]).astimezone(UTC) <= datetime.now(UTC)
):
return None
return decoded
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
@@ -0,0 +1,249 @@
"""Offline, passphrase-protected recovery bundle codec.
The binary format is deliberately small and versioned so validation can reject
unsupported inputs before attempting expensive password derivation. Every
failure while parsing or authenticating a bundle is reported as the same error
so callers cannot distinguish a malformed bundle from a wrong passphrase.
"""
from __future__ import annotations
import json
import os
import stat
import struct
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class RecoveryBundleError(ValueError):
"""A non-disclosing recovery bundle validation failure."""
class RecoveryBundlePathError(ValueError):
"""A requested recovery bundle path cannot be used safely."""
_MAGIC = b"BTREC"
_VERSION = 1
_KDF_ARGON2ID = 1
_SALT_BYTES = 16
_NONCE_BYTES = 12
_KEY_BYTES = 32
_TAG_BYTES = 16
_TIME_COST = 3
_MEMORY_COST_KIB = 65_536
_PARALLELISM = 1
_MAX_PASSPHRASE_BYTES = 4_096
_MAX_PLAINTEXT_BYTES = 8 * 1024 * 1024
# magic, version, KDF id, Argon2 time/memory/parallelism, salt/nonce lengths,
# and the AES-GCM ciphertext (including tag) length.
_HEADER = struct.Struct(">5sBBIIHBBQ")
_MAX_BUNDLE_BYTES = _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _MAX_PLAINTEXT_BYTES + _TAG_BYTES
_ERROR = "recovery bundle is invalid"
def _invalid() -> RecoveryBundleError:
return RecoveryBundleError(_ERROR)
def _canonical_json(payload: Mapping[str, Any]) -> bytes:
try:
encoded = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
except (TypeError, ValueError) as error:
raise _invalid() from error
if not encoded or len(encoded) > _MAX_PLAINTEXT_BYTES:
raise _invalid()
return encoded
def _passphrase(value: bytes) -> bytes:
if not isinstance(value, bytes) or not value or len(value) > _MAX_PASSPHRASE_BYTES:
raise _invalid()
return value
def _derive_key(passphrase: bytes, salt: bytes) -> bytes:
return hash_secret_raw(
secret=passphrase,
salt=salt,
time_cost=_TIME_COST,
memory_cost=_MEMORY_COST_KIB,
parallelism=_PARALLELISM,
hash_len=_KEY_BYTES,
type=Type.ID,
)
def encrypt_bundle(payload: Mapping[str, Any], passphrase: bytes) -> bytes:
"""Serialize and encrypt a canonical recovery payload as a BTREC v1 bundle."""
plaintext = _canonical_json(payload)
secret = _passphrase(passphrase)
salt = os.urandom(_SALT_BYTES)
nonce = os.urandom(_NONCE_BYTES)
ciphertext_length = len(plaintext) + _TAG_BYTES
header = _HEADER.pack(
_MAGIC,
_VERSION,
_KDF_ARGON2ID,
_TIME_COST,
_MEMORY_COST_KIB,
_PARALLELISM,
_SALT_BYTES,
_NONCE_BYTES,
ciphertext_length,
)
ciphertext = AESGCM(_derive_key(secret, salt)).encrypt(nonce, plaintext, header)
return header + salt + nonce + ciphertext
def decrypt_bundle(encoded: bytes, passphrase: bytes) -> dict[str, Any]:
"""Authenticate and decode a BTREC v1 bundle without disclosing failure cause."""
try:
if not isinstance(encoded, bytes) or len(encoded) > _MAX_BUNDLE_BYTES:
raise _invalid()
if len(encoded) < _HEADER.size + _SALT_BYTES + _NONCE_BYTES + _TAG_BYTES:
raise _invalid()
(
magic,
version,
kdf_id,
time_cost,
memory_cost,
parallelism,
salt_length,
nonce_length,
ciphertext_length,
) = _HEADER.unpack(encoded[: _HEADER.size])
if (
magic != _MAGIC
or version != _VERSION
or kdf_id != _KDF_ARGON2ID
or time_cost != _TIME_COST
or memory_cost != _MEMORY_COST_KIB
or parallelism != _PARALLELISM
or salt_length != _SALT_BYTES
or nonce_length != _NONCE_BYTES
or ciphertext_length < _TAG_BYTES
or ciphertext_length > _MAX_PLAINTEXT_BYTES + _TAG_BYTES
or len(encoded) != _HEADER.size + salt_length + nonce_length + ciphertext_length
):
raise _invalid()
secret = _passphrase(passphrase)
salt_start = _HEADER.size
nonce_start = salt_start + salt_length
ciphertext_start = nonce_start + nonce_length
plaintext = AESGCM(_derive_key(secret, encoded[salt_start:nonce_start])).decrypt(
encoded[nonce_start:ciphertext_start],
encoded[ciphertext_start:],
encoded[: _HEADER.size],
)
if not plaintext or len(plaintext) > _MAX_PLAINTEXT_BYTES:
raise _invalid()
payload = json.loads(plaintext.decode("utf-8"))
if not isinstance(payload, dict):
raise _invalid()
# Reject non-canonical encodings to make catalog serialization deterministic.
if _canonical_json(payload) != plaintext:
raise _invalid()
return payload
except (
InvalidTag,
UnicodeDecodeError,
json.JSONDecodeError,
struct.error,
ValueError,
) as error:
if isinstance(error, RecoveryBundleError):
raise error
raise _invalid() from error
def _check_path_components(path: Path) -> None:
if not path.is_absolute() or path.name in {"", ".", ".."}:
raise RecoveryBundlePathError("recovery bundle path is unsafe")
current = Path(path.anchor)
for component in path.parts[1:-1]:
current /= component
try:
info = current.lstat()
except OSError as error:
raise RecoveryBundlePathError("recovery bundle path is unsafe") from error
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
raise RecoveryBundlePathError("recovery bundle path is unsafe")
def write_bundle_exclusive(path: Path, encoded: bytes) -> None:
"""Write a bundle once with restrictive permissions and no symlink following."""
if not isinstance(encoded, bytes) or not encoded or len(encoded) > _MAX_BUNDLE_BYTES:
raise RecoveryBundlePathError("recovery bundle output is unsafe")
_check_path_components(path)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
except OSError as error:
raise RecoveryBundlePathError("recovery bundle output is unsafe") from error
try:
info = path.lstat()
if (
stat.S_ISLNK(info.st_mode)
or not stat.S_ISREG(info.st_mode)
or stat.S_IMODE(info.st_mode) != 0o600
):
path.unlink(missing_ok=True)
raise RecoveryBundlePathError("recovery bundle output is unsafe")
except OSError as error:
raise RecoveryBundlePathError("recovery bundle output is unsafe") from error
def read_bundle_file(path: Path) -> bytes:
"""Read a regular, non-symlink bundle with a bounded size."""
_check_path_components(path)
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
with os.fdopen(descriptor, "rb") as handle:
info = os.fstat(handle.fileno())
if (
not stat.S_ISREG(info.st_mode)
or info.st_size <= 0
or info.st_size > _MAX_BUNDLE_BYTES
):
raise RecoveryBundlePathError("recovery bundle input is unsafe")
return handle.read()
except RecoveryBundlePathError:
raise
except OSError as error:
raise RecoveryBundlePathError("recovery bundle input is unsafe") from error
def read_passphrase_fd(fd: int) -> bytes:
"""Read one newline-terminated passphrase from an inherited file descriptor."""
if not isinstance(fd, int) or fd < 0:
raise RecoveryBundleError("recovery passphrase is unavailable")
try:
value = os.read(fd, _MAX_PASSPHRASE_BYTES + 2)
except OSError as error:
raise RecoveryBundleError("recovery passphrase is unavailable") from error
if value.endswith(b"\r\n"):
value = value[:-2]
elif value.endswith(b"\n"):
value = value[:-1]
if not value or len(value) > _MAX_PASSPHRASE_BYTES:
raise RecoveryBundleError("recovery passphrase is unavailable")
return value
@@ -0,0 +1,41 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
SECRET_KEYS = frozenset(
{
"password",
"password_hash",
"secret",
"token",
"token_hash",
"authorization",
"cookie",
"ciphertext",
"master_key",
"private_key",
}
)
def redact(value: Any, *, canaries: Sequence[str] = ()) -> Any:
"""Return a recursively redacted copy safe for operator-visible sinks."""
if isinstance(value, Mapping):
return {
str(key): (
"[REDACTED]" if str(key).lower() in SECRET_KEYS else redact(item, canaries=canaries)
)
for key, item in value.items()
}
if isinstance(value, list):
return [redact(item, canaries=canaries) for item in value]
if isinstance(value, tuple):
return tuple(redact(item, canaries=canaries) for item in value)
if isinstance(value, str):
result = value
for canary in canaries:
if canary:
result = result.replace(canary, "[REDACTED]")
return result
return value
@@ -0,0 +1,102 @@
from __future__ import annotations
import os
import stat
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from backup_tool.config import Settings
from backup_tool.ids import new_uuid7
class RepositoryKeyError(ValueError):
pass
def _directory(settings: Settings) -> Path:
directory = settings.data_dir / "repository-data-keys"
directory.mkdir(mode=0o700, exist_ok=True)
if (
directory.is_symlink()
or not directory.is_dir()
or stat.S_IMODE(directory.stat().st_mode) != 0o700
):
raise RepositoryKeyError("repository data key directory is unsafe")
return directory
def create_data_key(settings: Settings, repository_id: str) -> tuple[str, Path]:
key_id = str(new_uuid7())
directory = _directory(settings)
path = directory / f"{repository_id}.{key_id}.key"
try:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(os.urandom(32))
handle.flush()
os.fsync(handle.fileno())
except OSError as error:
raise RepositoryKeyError("repository data key cannot be created") from error
if stat.S_IMODE(path.stat().st_mode) != 0o600 or path.is_symlink():
path.unlink(missing_ok=True)
raise RepositoryKeyError("repository data key is unsafe")
return key_id, path
def install_data_key(settings: Settings, repository_id: str, key_id: str, key: bytes) -> Path:
"""Install recovered key material once; never replace an existing key file."""
if len(key) != 32 or "/" in repository_id or "/" in key_id:
raise RepositoryKeyError("repository data key is invalid")
path = _directory(settings) / f"{repository_id}.{key_id}.key"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(key)
handle.flush()
os.fsync(handle.fileno())
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
path.unlink(missing_ok=True)
raise RepositoryKeyError("repository data key is unsafe")
except OSError as error:
raise RepositoryKeyError("repository data key cannot be installed") from error
return path
def object_aad(repository_id: str, key_id: str, kind: str, identity: str) -> bytes:
if kind not in {"blob", "manifest"} or not all((repository_id, key_id, identity)):
raise RepositoryKeyError("encrypted object metadata is invalid")
return f"BTENC:1:{repository_id}:{key_id}:{kind}:{identity}".encode()
def encrypt_object(key: bytes, aad: bytes, plaintext: bytes) -> bytes:
if len(key) != 32:
raise RepositoryKeyError("repository data key is unavailable")
nonce = os.urandom(12)
return b"BTENC\x01" + nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def decrypt_object(key: bytes, aad: bytes, stored: bytes) -> bytes:
if len(key) != 32 or not stored.startswith(b"BTENC\x01") or len(stored) < 35:
raise RepositoryKeyError("encrypted object is invalid")
try:
return AESGCM(key).decrypt(stored[6:18], stored[18:], aad)
except InvalidTag as error:
raise RepositoryKeyError("encrypted object is invalid") from error
def load_data_key(settings: Settings, repository_id: str, key_id: str) -> bytes:
path = _directory(settings) / f"{repository_id}.{key_id}.key"
try:
if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600:
raise RepositoryKeyError("repository data key is unsafe")
key = path.read_bytes()
except OSError as error:
raise RepositoryKeyError("repository data key is unavailable") from error
if len(key) != 32:
raise RepositoryKeyError("repository data key is unavailable")
return key
@@ -0,0 +1,36 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class EnvelopeCipher:
"""Purpose-bound authenticated encryption for persisted secret values."""
def __init__(self, master_key: bytes):
self._key = hashlib.sha256(master_key).digest()
self.key_id = hashlib.sha256(b"backup-tool-key-id\0" + self._key).hexdigest()[:24]
self._cipher = AESGCM(self._key)
@classmethod
def from_file(cls, path: Path) -> EnvelopeCipher:
return cls(path.read_bytes())
@staticmethod
def _associated_data(purpose: str, version: int) -> bytes:
return f"backup-tool-secret:{purpose}:v{version}".encode()
def encrypt(self, value: str, *, purpose: str, version: int) -> tuple[bytes, str]:
nonce = os.urandom(12)
encrypted = self._cipher.encrypt(
nonce, value.encode(), self._associated_data(purpose, version)
)
return nonce + encrypted, self.key_id
def decrypt(self, ciphertext: bytes, *, purpose: str, version: int) -> str:
nonce, encrypted = ciphertext[:12], ciphertext[12:]
plaintext = self._cipher.decrypt(nonce, encrypted, self._associated_data(purpose, version))
return plaintext.decode()
+121
View File
@@ -0,0 +1,121 @@
"""Fail-closed, DNS-rebinding-resistant webhook egress validation."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from urllib.parse import SplitResult, urlsplit
class SSRFError(ValueError):
"""The callback target is not safe for the notification egress boundary."""
Resolver = Callable[[str, int], Awaitable[Sequence[str]]]
_ALLOWED_PORTS = frozenset({80, 443, 8080, 8443})
def _is_global(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""ipaddress.is_global misses some policy-important mapped/special ranges."""
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
return _is_global(address.ipv4_mapped)
return bool(address.is_global) and not any(
(
address.is_loopback,
address.is_private,
address.is_link_local,
address.is_multicast,
address.is_unspecified,
address.is_reserved,
)
)
def validate_webhook_url(value: str) -> SplitResult:
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as error:
raise SSRFError("webhook URL is malformed") from error
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise SSRFError("webhook URL must be absolute HTTP(S)")
if parsed.username is not None or parsed.password is not None or parsed.fragment:
raise SSRFError("webhook URL credentials and fragments are forbidden")
if len(value) > 2048 or any(character.isspace() for character in value):
raise SSRFError("webhook URL is malformed")
if port is not None and port not in _ALLOWED_PORTS:
raise SSRFError("webhook URL port is not permitted")
try:
ipaddress.ip_address(parsed.hostname)
except ValueError:
pass
else:
# Callback literals are never accepted: names are resolved immediately
# before every request and connected addresses are pinned/rechecked.
raise SSRFError("literal IP webhook targets are forbidden")
return parsed
async def system_resolver(hostname: str, port: int) -> Sequence[str]:
records = await asyncio.get_running_loop().getaddrinfo(
hostname, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP
)
addresses: set[str] = set()
for record in records:
socket_address = record[4]
if socket_address and isinstance(socket_address[0], str):
addresses.add(socket_address[0])
return tuple(sorted(addresses))
async def resolve_public_addresses(
hostname: str, port: int, resolver: Resolver = system_resolver
) -> tuple[str, ...]:
try:
candidates = tuple(await resolver(hostname, port))
except (TimeoutError, OSError) as error:
raise SSRFError("webhook DNS resolution failed") from error
if not candidates:
raise SSRFError("webhook hostname has no addresses")
approved: list[str] = []
for candidate in candidates:
try:
address = ipaddress.ip_address(candidate)
except ValueError as error:
raise SSRFError("webhook resolver returned an invalid address") from error
if not _is_global(address):
# One unsafe answer poisons the hostname, including mixed public/private.
raise SSRFError("webhook hostname resolves to a non-public address")
approved.append(str(address))
return tuple(approved)
@dataclass(frozen=True)
class ResolvedWebhookTarget:
url: SplitResult
port: int
addresses: tuple[str, ...]
async def resolve_webhook_target(
value: str, resolver: Resolver = system_resolver
) -> ResolvedWebhookTarget:
parsed = validate_webhook_url(value)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
addresses = await resolve_public_addresses(parsed.hostname or "", port, resolver)
return ResolvedWebhookTarget(url=parsed, port=port, addresses=addresses)
def verify_connected_peer(peername: object, approved: Sequence[str]) -> str:
if not isinstance(peername, tuple) or not peername or not isinstance(peername[0], str):
raise SSRFError("webhook peer address is unavailable")
try:
peer = str(ipaddress.ip_address(peername[0]))
except ValueError as error:
raise SSRFError("webhook peer address is invalid") from error
if peer not in approved:
raise SSRFError("webhook peer changed after DNS resolution")
return peer
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
"""Pinned-host-key, forced-SFTP-only source reader.
The adapter has no command-channel API. It authenticates with the caller's
already decrypted private key only after verifying the configured host key.
"""
from __future__ import annotations
import asyncio
import hmac
import io
import socket
import stat
from collections.abc import AsyncIterator, Callable, Iterator
from contextlib import suppress
from typing import Any
import paramiko # type: ignore[import-untyped]
from backup_tool.adapters import Entry, SourceError
from backup_tool.config import Settings
from backup_tool.ssh_source import SSHSourcePublicConfig
TransportFactory = Callable[[str, int, float], Any]
SFTPFactory = Callable[[Any], Any]
def _transport_for(hostname: str, port: int, timeout: float) -> Any:
connection = socket.create_connection((hostname, port), timeout=timeout)
connection.settimeout(timeout)
return paramiko.Transport(connection)
def _sftp_for(transport: Any) -> Any:
return paramiko.SFTPClient.from_transport(transport)
def _next_or_none(iterator: Iterator[Any]) -> Any | None:
try:
return next(iterator)
except StopIteration:
return None
def load_private_key(value: str) -> Any:
"""Load only unencrypted Ed25519, ECDSA, or sufficiently strong RSA keys."""
for key_type in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
try:
key = key_type.from_private_key(io.StringIO(value), password=None)
except (paramiko.SSHException, ValueError):
continue
if isinstance(key, paramiko.RSAKey) and key.get_bits() < 3072:
raise SourceError(
"SSH private key algorithm is not permitted", reason_code="source_auth"
)
return key
raise SourceError("SSH private key algorithm is not permitted", reason_code="source_auth")
class SSHAdapter:
"""A stateful SFTP reader rooted at the forced-SFTP account chroot only."""
def __init__(
self,
config: SSHSourcePublicConfig,
private_key: str,
settings: Settings,
*,
transport_factory: TransportFactory = _transport_for,
sftp_factory: SFTPFactory = _sftp_for,
) -> None:
self.config = config
self._private_key = load_private_key(private_key)
self.settings = settings
self._transport_factory = transport_factory
self._sftp_factory = sftp_factory
self._transport: Any | None = None
self._sftp: Any | None = None
self._files: dict[str, tuple[int, int]] = {}
def validate_config(self) -> None:
if self.config.root != "/": # defensive: persisted JSON can bypass API validation
raise SourceError(
"SSH source root must be the forced-SFTP chroot", reason_code="source_invalid"
)
def _connect(self) -> None:
if self._sftp is not None:
return
self.validate_config()
transport = self._transport_factory(
self.config.hostname, self.config.port, self.settings.ssh_connect_timeout_seconds
)
self._transport = transport
try:
transport.start_client(timeout=self.settings.ssh_connect_timeout_seconds)
algorithm, encoded_key = self.config.host_key.split(" ", 1)
server_key = transport.get_remote_server_key()
if server_key.get_name() != algorithm or not hmac.compare_digest(
server_key.get_base64(), encoded_key
):
raise SourceError(
"SSH host key does not match configured pin", reason_code="source_trust"
)
# Authentication deliberately occurs only after the exact pin comparison.
transport.auth_publickey(self.config.username, self._private_key)
sftp = self._sftp_factory(transport)
channel = sftp.get_channel()
channel.settimeout(self.settings.ssh_operation_timeout_seconds)
self._sftp = sftp
except SourceError:
self._close_sync()
raise
except paramiko.AuthenticationException as error:
self._close_sync()
raise SourceError("SSH authentication failed", reason_code="source_auth") from error
except (TimeoutError, OSError) as error:
self._close_sync()
raise SourceError(
"SSH source is unavailable", reason_code="source_unavailable"
) from error
except (paramiko.SSHException, ValueError) as error:
self._close_sync()
raise SourceError(
"SSH source connection failed", reason_code="source_unavailable"
) from error
def _sftp_client(self) -> Any:
if self._sftp is None:
raise SourceError("SSH source is unavailable", reason_code="source_unavailable")
return self._sftp
def _read_client(self) -> Any:
if self._transport is None:
raise SourceError("SSH source is unavailable", reason_code="source_unavailable")
client = self._sftp_factory(self._transport)
client.get_channel().settimeout(self.settings.ssh_operation_timeout_seconds)
return client
@staticmethod
def _relative(parent: str, name: object) -> str:
if not isinstance(name, str) or not name or name in {".", ".."}:
raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid")
if "/" in name or "\\" in name or "\x00" in name:
raise SourceError("SSH source returned an invalid entry", reason_code="source_invalid")
return name if not parent else f"{parent}/{name}"
@staticmethod
def _remote_path(relative: str) -> str:
if (
not relative
or relative.startswith("/")
or "\\" in relative
or ".." in relative.split("/")
):
raise SourceError("SSH source entry is invalid", reason_code="source_invalid")
return f"/{relative}"
@staticmethod
def _entry_from_attributes(path: str, attributes: Any) -> Entry:
mode = getattr(attributes, "st_mode", None)
if not isinstance(mode, int):
raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid")
if stat.S_ISLNK(mode):
raise SourceError("SSH source symlinks are not supported", reason_code="source_invalid")
size = getattr(attributes, "st_size", 0)
mtime = getattr(attributes, "st_mtime", 0)
if not isinstance(size, int) or size < 0 or not isinstance(mtime, int):
raise SourceError("SSH source entry metadata is invalid", reason_code="source_invalid")
if stat.S_ISDIR(mode):
return Entry(path, "directory", 0, stat.S_IMODE(mode), mtime * 1_000_000_000)
if stat.S_ISREG(mode):
return Entry(path, "file", size, stat.S_IMODE(mode), mtime * 1_000_000_000)
raise SourceError("SSH source contains an unsupported entry", reason_code="source_invalid")
async def probe(self) -> dict[str, int]:
count = 0
try:
async for entry in self.enumerate_entries():
if entry.kind == "file":
count += 1
return {"entry_count": count}
finally:
await self.close()
async def enumerate_entries(self) -> AsyncIterator[Entry]:
await asyncio.to_thread(self._connect)
pending = [""]
entry_count = 0
try:
while pending:
parent = pending.pop()
remote_parent = "/" if not parent else self._remote_path(parent)
try:
iterator = await asyncio.to_thread(
self._sftp_client().listdir_iter,
remote_parent,
read_aheads=self.settings.ssh_list_read_aheads,
)
while (
attributes := await asyncio.to_thread(_next_or_none, iterator)
) is not None:
path = self._relative(parent, getattr(attributes, "filename", None))
entry_count += 1
if entry_count > self.settings.ssh_max_entries:
raise SourceError(
"SSH source entry limit exceeded", reason_code="source_limit"
)
entry = self._entry_from_attributes(path, attributes)
if entry.kind == "directory":
if path.count("/") + 1 > self.settings.ssh_max_traversal_depth:
raise SourceError(
"SSH source traversal depth exceeded",
reason_code="source_limit",
)
pending.append(path)
else:
self._files[path] = (entry.size, entry.mtime_ns)
yield entry
except SourceError:
raise
except (TimeoutError, OSError, paramiko.SSHException) as error:
raise SourceError(
"SSH source enumeration failed", reason_code="source_unavailable"
) from error
except BaseException:
await self.close()
raise
async def open_content(self, path: str) -> AsyncIterator[bytes]:
expected = self._files.get(path)
if expected is None:
raise SourceError("SSH source entry was not enumerated", reason_code="source_invalid")
remote_path = self._remote_path(path)
handle: Any | None = None
reader: Any | None = None
try:
await asyncio.to_thread(self._connect)
reader = await asyncio.to_thread(self._read_client)
assert reader is not None
attributes = await asyncio.to_thread(reader.lstat, remote_path)
entry = self._entry_from_attributes(path, attributes)
if entry.kind != "file" or (entry.size, entry.mtime_ns) != expected:
raise SourceError(
"SSH source file changed during backup", reason_code="source_changed"
)
handle = await asyncio.to_thread(
reader.open,
remote_path,
"rb",
self.settings.ssh_read_chunk_bytes,
)
assert handle is not None
while chunk := await asyncio.to_thread(handle.read, self.settings.ssh_read_chunk_bytes):
if not isinstance(chunk, bytes) or len(chunk) > self.settings.ssh_read_chunk_bytes:
raise SourceError(
"SSH source returned an invalid read", reason_code="source_invalid"
)
yield chunk
after = await asyncio.to_thread(reader.lstat, remote_path)
verified = self._entry_from_attributes(path, after)
if verified.kind != "file" or (verified.size, verified.mtime_ns) != expected:
raise SourceError(
"SSH source file changed during backup", reason_code="source_changed"
)
except SourceError:
raise
except (TimeoutError, OSError, paramiko.SSHException) as error:
raise SourceError("SSH source read failed", reason_code="source_unavailable") from error
finally:
if handle is not None:
await asyncio.to_thread(handle.close)
if reader is not None and reader is not self._sftp:
await asyncio.to_thread(reader.close)
def _close_sync(self) -> None:
sftp, transport = self._sftp, self._transport
self._sftp = None
self._transport = None
if sftp is not None:
with suppress(OSError, paramiko.SSHException):
sftp.close()
if transport is not None:
with suppress(OSError, paramiko.SSHException):
transport.close()
async def close(self) -> None:
await asyncio.to_thread(self._close_sync)
+72
View File
@@ -0,0 +1,72 @@
"""Closed public configuration for the staged SSH source capability.
This module deliberately describes configuration only. It must not create a
network transport or load a private key; those capabilities are outside this
slice.
"""
from __future__ import annotations
import base64
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
SSH_PRIVATE_KEY_PURPOSE = "ssh_private_key"
_ALLOWED_HOST_KEY_ALGORITHMS = frozenset(
{
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"rsa-sha2-256",
"rsa-sha2-512",
}
)
def _has_control_or_space(value: str) -> bool:
return any(character.isspace() or ord(character) < 32 for character in value)
class SSHSourcePublicConfig(BaseModel):
"""The public, SFTP-chroot-only portion of an SSH source definition."""
model_config = ConfigDict(extra="forbid", strict=True)
hostname: str = Field(min_length=1, max_length=253)
port: int = Field(ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
host_key: str = Field(min_length=1, max_length=16384)
# The server-side forced-SFTP account's chroot is the only permitted root.
root: Literal["/"]
@field_validator("hostname")
@classmethod
def validate_hostname(cls, value: str) -> str:
if _has_control_or_space(value) or any(character in value for character in "/\\@?#"):
raise ValueError("SSH hostname is invalid")
return value
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
if _has_control_or_space(value) or any(character in value for character in "/\\:@"):
raise ValueError("SSH username is invalid")
return value
@field_validator("host_key")
@classmethod
def validate_host_key(cls, value: str) -> str:
algorithm, separator, encoded_key = value.partition(" ")
if not separator or not algorithm or not encoded_key or " " in encoded_key:
raise ValueError("SSH host key must be an algorithm and base64 key")
if algorithm not in _ALLOWED_HOST_KEY_ALGORITHMS:
raise ValueError("SSH host key algorithm is not supported")
try:
decoded = base64.b64decode(encoded_key, validate=True)
except (ValueError, UnicodeEncodeError) as error:
raise ValueError("SSH host key is not valid base64") from error
if not decoded:
raise ValueError("SSH host key is empty")
return value
+43
View File
@@ -0,0 +1,43 @@
"""Dedicated HTTP runtime role."""
from __future__ import annotations
import stat
import uvicorn
from backup_tool.config import Settings
from backup_tool.observability.logging import configure_logging, log_event
def _prepare_socket(settings: Settings) -> str:
path = settings.web_socket_path
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
try:
existing = path.lstat()
except FileNotFoundError:
return str(path)
if not stat.S_ISSOCK(existing.st_mode):
raise RuntimeError("web socket path is not a socket")
path.unlink()
return str(path)
def run_web(settings: Settings) -> int:
"""Run exactly one ASGI server without reload or embedded background roles."""
from backup_tool.api.app import create_app
configure_logging("web", settings.log_level)
server = uvicorn.Server(
uvicorn.Config(
create_app(settings),
uds=_prepare_socket(settings),
log_level=settings.log_level.lower(),
reload=False,
workers=1,
log_config=None,
)
)
server.run()
log_event("role_stopped", role="web")
return 0
+315
View File
@@ -0,0 +1,315 @@
"""Single-node durable worker role.
The worker owns leases and performs repository I/O outside the API process.
"""
from __future__ import annotations
import asyncio
import contextlib
import importlib
import signal
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import cast
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backup_tool.config import Settings
from backup_tool.db.engine import create_engine
from backup_tool.db.models import Backup, Execution, Job, Repository, Restore, Source
from backup_tool.execution import (
claim,
complete_cancellation,
heartbeat,
record_event,
recover_stale,
transition,
)
from backup_tool.faults import FaultInjector, NoFault
from backup_tool.gc import process_retention_gc
from backup_tool.notifications.dispatcher import dispatch_one, recover_notification_leases
from backup_tool.notifications.events import emit_event
from backup_tool.observability.logging import configure_logging, log_event
from backup_tool.repository import reconcile_key_rotations
from backup_tool.security.secrets import EnvelopeCipher
snapshot = importlib.import_module("backup_tool.snapshot")
SnapshotError = snapshot.SnapshotError
SnapshotIntegrityError = snapshot.SnapshotIntegrityError
class Worker:
def __init__(
self,
settings: Settings,
*,
owner: str | None = None,
fault_injector: FaultInjector | None = None,
) -> None:
self.settings = settings
self.owner = owner or f"worker-{uuid4()}"
self.fault_injector = fault_injector or NoFault()
self._stopping = asyncio.Event()
self.engine = create_engine(settings)
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
self.cipher = EnvelopeCipher.from_file(settings.master_key_file)
self._execution_turns = 0
self._next_maintenance_at: datetime | None = None
async def startup(self) -> int:
async with self.sessions() as db:
rotations = await reconcile_key_rotations(self.settings, db)
publications = cast(int, await snapshot.reconcile_publications(self.settings, db))
restored = cast(int, await snapshot.reconcile_restores(self.settings, db))
recovered_executions = await recover_stale(db)
recovered_deliveries = await recover_notification_leases(db)
maintenance = await process_retention_gc(db)
self._next_maintenance_at = datetime.now(UTC) + timedelta(seconds=60)
return (
rotations
+ publications
+ restored
+ recovered_executions
+ recovered_deliveries
+ maintenance.tombstoned
)
async def _run_restore(self, db: AsyncSession) -> bool:
restore_id = await db.scalar(
select(Restore.id)
.where(Restore.state == "queued")
.order_by(Restore.created_at)
.limit(1)
)
if restore_id is None:
return False
result = await db.execute(
update(Restore)
.where(Restore.id == restore_id, Restore.state == "queued")
.values(state="running")
)
if getattr(result, "rowcount", 0) != 1:
await db.rollback()
return False
await db.commit()
restore = await db.get(Restore, restore_id)
if restore is None:
return False
try:
backup = await db.get(Backup, restore.backup_id)
if backup is None or backup.integrity != "verified" or backup.tombstoned_at is not None:
raise SnapshotError("backup is unavailable")
execution = await db.get(Execution, backup.execution_id)
if execution is None:
raise SnapshotError("backup execution is unavailable")
job = await db.get(Job, execution.job_id)
if job is None:
raise SnapshotError("backup job is unavailable")
repository = await db.get(Repository, job.repository_id)
if repository is None:
raise SnapshotError("backup repository is unavailable")
restore.result = await snapshot.restore_full_snapshot(
self.settings, db, restore, backup, repository
)
restore.state = "committed"
await emit_event(
db,
"restore.committed",
correlation_id=restore.id,
resource={"restore_id": restore.id, "backup_id": restore.backup_id},
payload={"dry_run": restore.dry_run, "outcome": "committed"},
deduplication_key=f"restore:{restore.id}:committed",
)
await db.commit()
except SnapshotIntegrityError:
await db.rollback()
failed = await db.get(Restore, restore_id)
if failed is not None:
corrupted_backup = await db.get(Backup, failed.backup_id)
if corrupted_backup is not None:
corrupted_backup.integrity = "corrupt"
failed.state = "failed"
failed.result = {"reason": "restore_failed"}
await emit_event(
db,
"restore.failed",
correlation_id=failed.id,
resource={"restore_id": failed.id, "backup_id": failed.backup_id},
payload={"reason_code": "restore_failed"},
deduplication_key=f"restore:{failed.id}:failed",
)
await db.commit()
except SnapshotError:
await db.rollback()
failed = await db.get(Restore, restore_id)
if failed is not None:
failed.state = "failed"
failed.result = {"reason": "restore_failed"}
await emit_event(
db,
"restore.failed",
correlation_id=failed.id,
resource={"restore_id": failed.id, "backup_id": failed.backup_id},
payload={"reason_code": "restore_failed"},
deduplication_key=f"restore:{failed.id}:failed",
)
await db.commit()
return True
async def _run_maintenance(self, db: AsyncSession) -> None:
now = datetime.now(UTC)
if self._next_maintenance_at is None or now >= self._next_maintenance_at:
await process_retention_gc(db, now)
self._next_maintenance_at = now + timedelta(seconds=60)
async def run_once(self) -> bool:
if self._stopping.is_set():
return False
async with self.sessions() as db:
# Retention/GC is a worker responsibility, but is rate-limited so it
# cannot turn a sustained backup queue into a metadata polling loop.
await self._run_maintenance(db)
# Never let an always-nonempty execution queue starve due notifications.
if self._execution_turns >= 1 and await dispatch_one(
db, self.settings, self.cipher, self.owner
):
self._execution_turns = 0
return True
if self._stopping.is_set():
return False
execution_id = await db.scalar(
select(Execution.id)
.where(Execution.state == "queued")
.order_by(Execution.created_at)
.limit(1)
)
if execution_id is None:
if await self._run_restore(db):
return True
return await dispatch_one(db, self.settings, self.cipher, self.owner)
execution = await claim(db, execution_id, self.owner)
if execution is None:
return False
self._execution_turns += 1
# Reload after the claim: a control request can race the lease acquisition.
await db.refresh(execution)
if execution.state == "cancelling":
await complete_cancellation(db, execution.id, self.owner)
return True
now = datetime.now(UTC)
result = await db.execute(
update(Execution)
.where(
Execution.id == execution.id,
Execution.lease_owner == self.owner,
Execution.lease_expires_at >= now,
Execution.state == "preparing",
)
.values(state=transition("preparing", "running"), started_at=now)
)
if getattr(result, "rowcount", 0) == 1:
await db.refresh(execution)
await record_event(db, execution)
await db.commit()
await heartbeat(db, execution.id, self.owner)
failure_detail: str | None = None
failure_reason = "transient_io"
try:
job = await db.get(Job, execution.job_id)
if job is None:
raise SnapshotError("execution job is unavailable")
source = await db.get(Source, job.source_id)
repository = await db.get(Repository, job.repository_id)
if source is None or repository is None:
raise SnapshotError("execution source or repository is unavailable")
backup = await snapshot.publish_full_snapshot(
self.settings,
db,
execution,
job,
source,
repository,
self.fault_injector,
cipher=self.cipher,
)
await db.flush()
await emit_event(
db,
"backup.committed",
correlation_id=execution.id,
resource={
"execution_id": execution.id,
"job_id": job.id,
"repository_id": repository.id,
"backup_id": backup.id,
},
payload={"integrity": backup.integrity, "effective_mode": job.requested_mode},
deduplication_key=f"backup:{backup.id}:committed",
)
await emit_event(
db,
"backup.verification_succeeded",
correlation_id=execution.id,
resource={"execution_id": execution.id, "backup_id": backup.id},
payload={"integrity": backup.integrity},
deduplication_key=f"backup:{backup.id}:verified",
)
execution.state = transition("running", "verifying")
await record_event(db, execution)
self.fault_injector.hit("metadata.before_commit")
await db.commit()
self.fault_injector.hit("metadata.after_commit")
execution.state = transition("verifying", "committed")
execution.completed_at = datetime.now(UTC)
execution.lease_owner = None
execution.lease_expires_at = None
await record_event(db, execution)
await db.commit()
snapshot.finalize_publication(Path(repository.root), execution.id)
except SnapshotError as error:
await db.rollback()
failure_detail = str(error)
failure_reason = error.reason_code
if failure_detail is not None:
failed = await db.get(Execution, execution_id)
if failed is None or failed.state not in {"preparing", "running", "verifying"}:
return True
failed.state = "failed"
failed.reason_code = failure_reason
failed.operator_message = failure_detail
failed.completed_at = datetime.now(UTC)
failed.lease_owner = None
failed.lease_expires_at = None
await record_event(db, failed)
await db.commit()
return True
async def run(self) -> None:
await self.startup()
while not self._stopping.is_set():
if not await self.run_once():
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stopping.wait(), timeout=0.25)
await self.engine.dispose()
def stop(self) -> None:
log_event("role_stopping", role="worker")
self._stopping.set()
def run_worker(settings: Settings) -> int:
configure_logging("worker", settings.log_level)
worker = Worker(settings)
loop = asyncio.new_event_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, worker.stop)
try:
loop.run_until_complete(worker.run())
finally:
loop.close()
log_event("role_stopped", role="worker")
return 0
@@ -0,0 +1,14 @@
{
"api_version": "v2",
"sources": ["local", "ssh"],
"repositories": ["local"],
"features": {
"email": true,
"encryption": true,
"mysql": false,
"postgresql": false,
"restore": true,
"tar_download": false,
"webhook": true
}
}
+22
View File
@@ -0,0 +1,22 @@
[
"authentication_failed",
"baseline_missing",
"cancelled_by_operator",
"capacity_exhausted",
"conflict_active_execution",
"corrupt_blob",
"corrupt_manifest",
"deletion_failed",
"forbidden",
"host_key_changed",
"host_key_unknown",
"invalid_configuration",
"not_found",
"permission_denied",
"source_changed",
"source_empty",
"timeout",
"transient_io",
"unsupported_entry",
"worker_lost"
]
@@ -0,0 +1,10 @@
{
"queued": ["cancelled", "preparing"],
"preparing": ["cancelling", "failed", "running"],
"running": ["cancelling", "failed", "verifying"],
"verifying": ["committed", "failed"],
"cancelling": ["cancelled", "failed"],
"committed": [],
"failed": [],
"cancelled": []
}
+14
View File
@@ -0,0 +1,14 @@
[
"blob.before_write",
"blob.after_write",
"blob.after_fsync",
"blob.before_rename",
"manifest.before_write",
"manifest.after_fsync",
"manifest.before_publish",
"metadata.before_commit",
"metadata.after_commit",
"restore.before_write",
"restore.after_fsync",
"restore.before_replace"
]
@@ -0,0 +1 @@
{"aggregates":{"entry_count":-1,"logical_bytes":-5,"stored_bytes":0},"backup_id":"bad","created_at":"not-a-time","effective_mode":"full","encryption_key_id":null,"entries":[],"exclusion_policy":{"matcher":"glob","patterns":[],"version":0},"execution_id":"bad","format_version":2,"job_id":"bad","manifest_digest":"short","repository_id":"bad","requested_mode":"full","source_consistency":{},"source_id":"bad"}
@@ -0,0 +1 @@
{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"md5","encryption":{"key_id":null,"mode":"none"},"format_version":2,"repository_id":"not-a-uuid"}
@@ -0,0 +1 @@
{"aggregates":{"entry_count":2,"logical_bytes":5,"stored_bytes":5},"backup_id":"0198c57f-0000-7000-8000-000000000006","created_at":"2026-07-27T00:00:00Z","effective_mode":"full","encryption_key_id":null,"entries":[{"blob_digest":null,"link_target":null,"metadata_support":["mode","mtime_ns"],"mode":493,"mtime_ns":0,"path":"data","size":0,"type":"directory"},{"blob_digest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","link_target":null,"metadata_support":["mode","mtime_ns"],"mode":420,"mtime_ns":0,"path":"data/hello.txt","size":5,"type":"file"}],"exclusion_policy":{"matcher":"gitignore","patterns":[],"version":1},"execution_id":"0198c57f-0000-7000-8000-000000000005","format_version":1,"job_id":"0198c57f-0000-7000-8000-000000000004","manifest_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","manifest_signature":{"algorithm":"ed25519","key_id":"manifest-signing-key-1","value":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"},"repository_id":"0198c57f-0000-7000-8000-000000000001","requested_mode":"full","source_consistency":{"adapter":"local","captured_at":"2026-07-27T00:00:00Z","evidence":{"snapshot":"stable"}},"source_id":"0198c57f-0000-7000-8000-000000000003"}
@@ -0,0 +1 @@
{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"sha256","encryption":{"key_id":null,"mode":"none"},"format_version":1,"repository_id":"0198c57f-0000-7000-8000-000000000001"}
@@ -0,0 +1,126 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://backup-tool.invalid/contracts/repository/v1/manifest.schema.json",
"title": "Backup Tool Manifest v1",
"type": "object",
"additionalProperties": false,
"required": [
"format_version", "backup_id", "repository_id", "source_id", "job_id", "execution_id",
"requested_mode", "effective_mode", "created_at", "source_consistency", "exclusion_policy",
"entries", "aggregates", "encryption_key_id", "manifest_digest", "manifest_signature"
],
"properties": {
"format_version": {"const": 1},
"backup_id": {"$ref": "#/$defs/uuidv7"},
"repository_id": {"$ref": "#/$defs/uuidv7"},
"source_id": {"$ref": "#/$defs/uuidv7"},
"job_id": {"$ref": "#/$defs/uuidv7"},
"execution_id": {"$ref": "#/$defs/uuidv7"},
"requested_mode": {"enum": ["full", "incremental"]},
"effective_mode": {"enum": ["full", "incremental"]},
"created_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"},
"source_consistency": {
"type": "object",
"additionalProperties": false,
"required": ["adapter", "captured_at", "evidence"],
"properties": {
"adapter": {"enum": ["local", "postgresql", "mysql"]},
"captured_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"},
"evidence": {"type": "object"}
}
},
"exclusion_policy": {
"type": "object",
"additionalProperties": false,
"required": ["matcher", "version", "patterns"],
"properties": {
"matcher": {"const": "gitignore"},
"version": {"type": "integer", "minimum": 1},
"patterns": {"type": "array", "items": {"type": "string"}}
}
},
"entries": {
"type": "array",
"items": {"$ref": "#/$defs/entry"}
},
"aggregates": {
"type": "object",
"additionalProperties": false,
"required": ["entry_count", "logical_bytes", "stored_bytes"],
"properties": {
"entry_count": {"type": "integer", "minimum": 0},
"logical_bytes": {"type": "integer", "minimum": 0},
"stored_bytes": {"type": "integer", "minimum": 0}
}
},
"encryption_key_id": {"type": ["string", "null"]},
"manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"manifest_signature": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "key_id", "value"],
"properties": {
"algorithm": {"const": "ed25519"},
"key_id": {"type": "string", "minLength": 1},
"value": {"type": "string", "pattern": "^[0-9a-f]{128}$"}
}
}
},
"$defs": {
"uuidv7": {
"type": "string",
"format": "uuid",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
},
"entry": {
"type": "object",
"additionalProperties": false,
"required": ["path", "type", "size", "blob_digest", "mode", "mtime_ns", "link_target", "metadata_support"],
"properties": {
"path": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"},
"type": {"enum": ["file", "directory", "symlink"]},
"size": {"type": "integer", "minimum": 0},
"blob_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
"mode": {"type": ["integer", "null"], "minimum": 0},
"mtime_ns": {"type": ["integer", "null"], "minimum": 0},
"link_target": {"type": ["string", "null"]},
"metadata_support": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
},
"allOf": [
{
"if": {"properties": {"type": {"const": "file"}}},
"then": {
"properties": {
"blob_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"link_target": {"type": "null"}
}
}
},
{
"if": {"properties": {"type": {"const": "directory"}}},
"then": {
"properties": {
"blob_digest": {"type": "null"},
"link_target": {"type": "null"},
"size": {"const": 0}
}
}
},
{
"if": {"properties": {"type": {"const": "symlink"}}},
"then": {
"properties": {
"blob_digest": {"type": "null"},
"link_target": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"
},
"size": {"const": 0}
}
}
}
]
}
}
}
@@ -0,0 +1,9 @@
[
{"raw": "file.txt", "normalized": "file.txt", "valid": true},
{"raw": "dir/./file.txt", "normalized": "dir/file.txt", "valid": true},
{"raw": "unicodé/文件.txt", "normalized": "unicodé/文件.txt", "valid": true},
{"raw": "/absolute", "normalized": null, "valid": false},
{"raw": "../escape", "normalized": null, "valid": false},
{"raw": "a\\b", "normalized": null, "valid": false},
{"raw": "", "normalized": null, "valid": false}
]
@@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://backup-tool.invalid/contracts/repository/v1/repository.schema.json",
"title": "Backup Tool Repository v1",
"type": "object",
"additionalProperties": false,
"required": ["repository_id", "format_version", "digest_algorithm", "compression", "encryption", "created_at"],
"allOf": [
{
"if": {"properties": {"encryption": {"properties": {"mode": {"const": "none"}}}}},
"then": {"properties": {"encryption": {"properties": {"key_id": {"type": "null"}}}}},
"else": {"properties": {"encryption": {"properties": {"key_id": {"type": "string", "format": "uuid", "minLength": 1}}}}}
}
],
"properties": {
"repository_id": {
"type": "string",
"format": "uuid",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
},
"format_version": {"const": 1},
"digest_algorithm": {"const": "sha256"},
"compression": {"enum": ["none", "zstd"]},
"encryption": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "key_id"],
"properties": {
"mode": {"enum": ["none", "aes-256-gcm"]},
"key_id": {"type": ["string", "null"], "minLength": 1}
}
},
"created_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]+)?Z$"}
}
}
+115
View File
@@ -0,0 +1,115 @@
name: backup-tool
x-app-base: &app-base
build:
context: .
dockerfile: Dockerfile
image: backup-tool:local
init: true
read_only: true
tmpfs:
- /tmp:mode=1777,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
environment:
BACKUP_TOOL_DATA_DIR: /var/lib/backup-tool
BACKUP_TOOL_DATABASE_URL: sqlite+aiosqlite:////var/lib/backup-tool/metadata.db
BACKUP_TOOL_REPOSITORY_ROOTS: '["/var/lib/backup-tool/repositories"]'
BACKUP_TOOL_LOCAL_SOURCE_ROOTS: '["/mnt/sources"]'
BACKUP_TOOL_RESTORE_ROOTS: '["/var/lib/backup-tool/restores"]'
BACKUP_TOOL_MASTER_KEY_FILE: /run/backup-tool-secrets/master.key
BACKUP_TOOL_WEB_SOCKET_PATH: /run/backup-tool/web.sock
BACKUP_TOOL_PUBLIC_BASE_URL: ${BACKUP_TOOL_PUBLIC_BASE_URL:-http://localhost:8080}
volumes:
- backup-tool-data:/var/lib/backup-tool
- backup-tool-runtime:/run/backup-tool
- type: bind
source: ${BACKUP_TOOL_SOURCES_DIR:-./sources}
target: /mnt/sources
read_only: true
- type: bind
source: ${BACKUP_TOOL_MASTER_KEY_FILE:-./secrets/master.key}
target: /run/backup-tool-secrets/master.key
read_only: true
services:
migrate:
<<: *app-base
command: ["migrate", "upgrade"]
restart: "no"
web:
<<: *app-base
command: ["web"]
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "backup-tool", "health", "web"]
interval: 10s
timeout: 10s
retries: 6
start_period: 0s
scheduler:
<<: *app-base
command: ["scheduler"]
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "backup-tool", "health", "scheduler"]
interval: 10s
timeout: 10s
retries: 6
start_period: 0s
worker:
<<: *app-base
command: ["worker"]
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "backup-tool", "health", "worker"]
interval: 10s
timeout: 10s
retries: 6
start_period: 0s
admin:
<<: *app-base
profiles: ["admin"]
command: ["admin", "--help"]
depends_on:
migrate:
condition: service_completed_successfully
proxy:
build:
context: ./frontend
dockerfile: Dockerfile
image: backup-tool-proxy:local
init: true
read_only: true
tmpfs:
- /tmp:mode=1777,size=64m
- /var/cache/nginx:uid=10001,gid=0,mode=0750,size=32m
- /var/run:uid=10001,gid=0,mode=0750,size=8m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ports:
- "127.0.0.1:${BACKUP_TOOL_PORT:-8080}:8080"
volumes:
- backup-tool-runtime:/run/backup-tool:ro
depends_on:
web:
condition: service_healthy
volumes:
backup-tool-data:
backup-tool-runtime:
+42
View File
@@ -0,0 +1,42 @@
# Backup Tool v2 documentation
## Reason for existence
This index points operators and contributors to the authoritative v2.0 procedures. Do not duplicate runbook steps in release evidence or issue comments.
## Start here
- [Project quick start](../README.md)
- [Upgrade and rollback](runbooks/upgrade.md)
- [Observability and alert response](runbooks/observability.md)
- [Disaster recovery](runbooks/disaster-recovery.md)
## Runbooks
| Need | Authoritative guide |
| --- | --- |
| Repository lifecycle | [repositories](runbooks/repositories.md) |
| Metadata protection | [metadata](runbooks/metadata.md) |
| Master and repository keys | [keys](runbooks/keys.md) |
| Encrypted recovery bundle | [recovery bundle](runbooks/recovery-bundle.md) |
| SSH source hardening | [SSH sources](runbooks/ssh-sources.md) |
| Notification operations | [notifications](runbooks/notifications.md) |
| Upgrade safety | [upgrade](runbooks/upgrade.md) |
| Host loss | [disaster recovery](runbooks/disaster-recovery.md) |
## Security boundaries
- [Repository encryption threat model](security/repository-encryption.md)
- [Notification egress and delivery policy](security/notifications.md)
- SSH sources require private-key authentication, exact host-key pinning, and a forced-SFTP chrooted account. No password, shell, agent, tunnel, or remote-command mode exists.
## Release records
[Release evidence](release/) records verification rather than replacing runbooks. v2.0 scale certification is documented in `release/m15-evidence.md` and `release/m15-scale-report.json`.
## Verify
```sh
grep -c '^## ' README.md
make check
```
+75
View File
@@ -0,0 +1,75 @@
# M0 Protocol Foundation Evidence
**Branch:** `feature/v2-reimplementation`
**RED commit:** `92f2aaa`
**GREEN commit:** commit containing this evidence file, immediately after `92f2aaa`
## RED
Command:
```bash
.venv/bin/python -m pytest \
tests/contract/test_repository_format.py \
tests/contract/test_no_v1.py -q
```
Exit: `1` (expected). Output summary:
```text
9 failed, 9 passed in 1.59s
```
The failures proved absent enforcement for UUIDv7/RFC3339, manifest signature and source-consistency shape, entry/blob/link rules, and runtime/config canaries for v1 database, payload, import, conversion, timestamp parsing, and old entry points.
## GREEN
Clean bootstrap command:
```bash
make setup
```
Exit: `0`. It created/reused `.venv`, installed `backend[dev]` through `.venv/bin/python`, installed the committed frontend lockfile with `npm ci`, and reported `0 vulnerabilities`.
Whole-M0 check:
```bash
make check
```
Exit: `0`. Output summary:
```text
v1 compatibility scan: OK
18 passed in 2.34s
All checks passed!
5 files already formatted
Success: no issues found in 2 source files
frontend TypeScript check: passed
frontend Vite production build: passed
```
Exact milestone acceptance plus required frontend build:
```bash
.venv/bin/python tools/forbidden_v1_scan.py . && \
.venv/bin/python -m pytest \
tests/contract/test_repository_format.py \
tests/contract/test_no_v1.py -q && \
npm --prefix frontend run build
```
Exit: `0`. Output summary:
```text
v1 compatibility scan: OK
18 passed in 2.24s
vite v8.1.5 production build completed
```
## Scope Confirmation
- No M1 runtime, database, UUID generator, API, or migration behavior was implemented.
- Obsolete v1 backend container/Compose entry points were removed because the config-scope canary gate proved they still invoked v1 code. M14 owns replacement deployment packaging.
- The v1 reference remains available at tag `v1-reference-2026-07-27`.
+62
View File
@@ -0,0 +1,62 @@
# M1 Runtime and Metadata Evidence
## RED
Commit: `8f78484 test(v2): define runtime and metadata contracts`
Command:
```bash
.venv/bin/python -m pytest tests/unit/test_config.py tests/integration/test_migrations.py -q
```
Observed result: exit 2 during collection because `backup_tool.clock` did not exist. This proves the test-only commit preceded runtime implementation.
## GREEN
Migration gate executed against a disposable absolute SQLite database:
```bash
cd backend
BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head
BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic downgrade base
BACKUP_TOOL_DATABASE_URL=sqlite+aiosqlite:////absolute/path/.m1-gate.db ../.venv/bin/python -m alembic upgrade head
../.venv/bin/python -m pytest ../tests/unit/test_config.py ../tests/integration/test_migrations.py -q
cd ..
make check
```
Observed results:
- Alembic upgrade, downgrade, and second upgrade each exited 0.
- Focused M1 suite: `13 passed`.
- Fast unit/contract suite: `28 passed`.
- Ruff check/format and strict mypy: passed.
- Frontend typecheck/build: passed.
- Disposable database removed after verification.
## Review-Fix RED/GREEN
RED commit: `45a526a test(v2): prove runtime dispatch and persistence invariants`
The focused M1 command exited 1 with five intended failures: role dispatch was absent, migrate did not dispatch Alembic, database roles accepted an unmigrated DB, persisted ORM IDs had no default, and migration connections had no shared SQLite configurator.
GREEN verification after implementing the missing behavior:
- CLI dispatch and `migrate upgrade` tests passed.
- Unmigrated worker startup was rejected before its handler ran.
- Persisted User and Repository IDs were UUIDv7.
- Generated and non-UTC supplied timestamps reloaded as aware UTC.
- Alembic connection observation reported `foreign_keys=1`, `journal_mode=wal`, and `busy_timeout=7000`.
- Baseline active-execution index, schedule uniqueness, and execution checks were present.
- Focused M1 suite: `19 passed`.
- Fast unit/contract suite: `30 passed`.
- Alembic upgrade/downgrade/upgrade, Ruff, strict mypy, frontend typecheck/build, forbidden-v1 scan, and `git diff --check`: passed.
## Hand Review
- Baseline revision contains explicit `op.create_table`, constraints, foreign keys, and indexes for all 14 v2 entities; it does not call `create_all`.
- Runtime and Alembic share one SQLite connection configurator; lock waiting uses the driver timeout rather than interpolated PRAGMA SQL.
- Startup compares the database Alembic revision with the current script head and rejects unmigrated databases.
- Every ORM identity has a UUIDv7 default; every persisted datetime uses the aware-UTC normalizing type.
- Runtime roles are explicit CLI subcommands and dispatch only after the schema gate; `migrate` invokes Alembic directly.
+29
View File
@@ -0,0 +1,29 @@
# M10 SSH source evidence
The released source capability is `ssh`: private-key-only, pinned-host-key,
forced-SFTP chroot access. No password, shell, command channel, agent, default
key discovery, or arbitrary remote root is supported.
## Fixture
`tests/compose.ssh.yaml` builds a test-only OpenSSH server. Each run generates
host and client Ed25519 keys under pytest `tmp_path`, mounts no committed keys,
and configures a dedicated `backup` account with `ChrootDirectory /home/backup`
and `ForceCommand internal-sftp`. The ordinary operator Compose stack is not
modified. Run `make test-ssh-integration` to build, run, and tear down the
fixture.
## Verification
- Fake transport tests cover pin mismatch before authentication/SFTP, bounded
reads, unsafe entries, and accepted/rejected private-key algorithms.
- The opt-in live test covers a private-key probe, backup, signed verification,
and restore through the forced-SFTP fixture.
- `make test-ssh-integration` passed after fixture isolation and SFTP-channel
concurrency fixes.
- Final `make check` passed: 107 unit/contract, 73 integration (one skipped),
15 fault, and 33 security tests; Ruff, mypy, TypeScript, and the frontend
build passed.
See `docs/runbooks/ssh-sources.md` for deployment prerequisites, rotation, and
containment limitations.
+55
View File
@@ -0,0 +1,55 @@
# M11 recovery import evidence
- Recovery bundles use the existing versioned `BTREC` Argon2id/AES-GCM codec
and now carry a version-2 authenticated catalog containing repositories,
key epochs, sources, jobs, executions, and backups required for encrypted
restore.
- `backup-tool admin recovery import` requires a migrated empty destination DB,
re-inspects every surviving repository through configured allowlists, writes
signing/data keys exclusively with mode `0600`, and rejects conflicts.
- Imported sources are `unavailable`; imported jobs are `archived` and disabled.
Imported metadata therefore supports existing restore records without silently
restarting backup schedules.
- The focused host-loss drill exports an encrypted backup, imports it into a
fresh metadata/key host, and restores the file byte-for-byte. It also proves
unsafe repository paths and non-empty destination metadata are rejected.
- Encrypted repository creation is enabled only after that drill passed and now
records its active data-key epoch atomically with repository metadata.
- Interrupted imports remove newly installed key files on handled failure; a
retry also safely adopts only exact, authenticated key files left by an
unclean process loss. Rotation writes an fsynced repository journal before
its DB transition; worker startup deterministically completes a committed
epoch transition or removes an uncommitted one while retaining old-active.
A stale rollback journal is cleared safely even when the unreferenced new key
was already deleted before the journal cleanup could run.
- Key-aware GC decrypts encrypted manifests using their declared epoch key and
remains fail-closed for absent, wrong, or corrupt keys/manifests. Restore
removes setuid, setgid, and sticky bits from captured modes. Migration 0007
refuses downgrade while key metadata is populated.
- Recovery catalog import preserves backup `created_at` and `tombstoned_at`.
- Snapshot staging roots, per-execution directories, blob directories, and
plaintext temporary blobs are created owner-only (`0700`/`0600`) independent
of umask and are rejected if their permissions are unsafe.
## Verification
```text
pytest tests/integration/test_encrypted_repository.py -q
5 passed
make test-fault
11 passed
make test-security
21 passed
make lint && make typecheck && make frontend-build
passed
make check
86 unit/contract, 53 integration, 12 fault, and 22 security tests passed;
Ruff, mypy, TypeScript, and frontend build passed.
git diff --check && git diff --cached --quiet
passed
```
+43
View File
@@ -0,0 +1,43 @@
# M12 evidence
- Migration revision: `0008_notification_outbox`
- Event schema version: `1`
- Catalog: 22 live stable IDs only. Deferred/unimplemented operation types are not public notification contracts.
- Delivery guarantee: durable at-least-once, stable event ID, leased worker retries; receiver deduplication is required.
## Green focused evidence
```text
PYTHONPATH=.:backend/src .venv/bin/python -m pytest \
tests/contract/test_notification_contract.py \
tests/integration/test_migrations.py \
tests/integration/test_all_operational_events_deliver.py \
tests/integration/test_notifications.py \
tests/fault/test_notification_retries.py \
tests/security/test_webhook_ssrf.py -q
28 passed (live-catalog contract, fair dispatch regression; no deferred event IDs).
# Scheduler-role service-path regression
PYTHONPATH=.:backend/src .venv/bin/python -m pytest \
tests/integration/test_scheduler_live_sync.py -q
1 passed (scheduler role service path)
```
The suite uses temporary SQLite/repository roots, a fake SMTP implementation, fake resolver inputs, and dispatcher monkeypatches; it performs no real webhook DNS, HTTP, or SMTP delivery. It verifies receiver-visible versioned webhook headers/signatures, STARTTLS-before-AUTH SMTP behavior, transient/permanent SMTP classification, persisted SMTP attempt limits, lease-abandoned attempt closure, selected-only test sends, manual-retry idempotency, and the absence of a plaintext-secret idempotency verifier. Behavioral producer tests cover every live catalog family: execution, schedule, backup/verification, restore, and retention. They also prove fair dispatch under an execution backlog, scheduler-role delivery, and worker-owned retention/GC maintenance. The CLI scheduler role now runs `SchedulerService`; worker maintenance runs durable retention/GC on startup and at bounded intervals.
## Quality evidence
- `make test-fast`: 89 passed.
- `make test-integration`: 61 passed in 27.00s (the execution wrapper nevertheless returned exit 124 at its fixed 30s wall limit).
- `make test-fault`: focused fair-dispatch regression passed.
- `make test-security`: 28 passed.
- `make lint` and `make typecheck`: passed.
- `make frontend-build`: passed.
- `git diff --check`: passed.
- staged-file check: no staged files.
Final verification: `make check` passed — 90 unit/contract, 61 integration, 14 fault, and 28 security tests; Ruff, mypy, TypeScript, and the frontend build passed. `git diff --check` and the staged-file check also passed.
## Rollback
Disable or archive subscriptions and stop worker dispatch. Do not delete notification events, deliveries, or attempts: they remain audit history. A fresh host recovery intentionally starts with no notification settings or credentials and must be reconfigured.
+20
View File
@@ -0,0 +1,20 @@
# M13 UI/OpenAPI evidence
- OpenAPI is deterministically exported to `openapi/v2.json`; generated browser client drift is checked by `npm --prefix frontend run api:check`.
- `text/event-stream` is declared in OpenAPI for execution events. The generated client intentionally emits `executionEventsUrl(...) -> URL`, not a misleading JSON `Promise`; UI opens that URL using browser `EventSource`.
- Notification delivery retry requests send an `Idempotency-Key` and the browser CSRF header.
- Recovery status is CLI/runbook-only. The browser has no export, import, bundle, key, or passphrase transfer control.
- v2.1 PostgreSQL, MySQL, and TAR/download controls remain absent.
## Green M13 checks
```text
npm --prefix frontend test -- --run # 9 passed
npm --prefix frontend run typecheck # passed
npm --prefix frontend run build # passed
npx --prefix frontend playwright test --config frontend/playwright.config.ts # 1 passed
.venv/bin/python tools/export_openapi.py --check openapi/v2.json # current
npm --prefix frontend run api:check # current
```
Final verification: `make check` passed — 90 unit/contract, 61 integration, 14 fault, and 28 security tests; Ruff, mypy, TypeScript, and the frontend build passed. `git diff --check` and the staged-file check also passed.
+34
View File
@@ -0,0 +1,34 @@
# M14 operations and packaging evidence
## Delivered
- Pinned non-root OCI application and proxy images; isolated web, scheduler, worker,
migrate, and admin roles; same-origin Unix-socket proxy; no reload or embedded roles.
The proxy port is loopback-bound by default (`127.0.0.1`), so the localhost public
URL cannot permit remote first-admin setup takeover. Operators exposing it through
an external reverse proxy must set a non-loopback public URL and bootstrap secret.
- Role-aware readiness, JSON structured logs, safe worker claim shutdown, and
dependency-free Prometheus metrics for request volume/duration, active/stale/failed
execution state, schedule lag, corrupt/unavailable repositories, and free space.
- SBOM generation at `tools/generate_sbom.py`, generated CycloneDX artifact
`m14-sbom.json`, and base-image/source provenance in `m14-provenance.md`.
- Metadata, repository, key, upgrade, disaster-recovery, and observability runbooks.
## Green verification
```text
docker compose config --quiet # passed (loopback port binding)
docker compose build --pull # passed
make test-e2e # 1 passed in 51.69s (loopback regression)
make check # passed after loopback regression
91 unit/contract, 61 integration, 15 fault, 33 security
Ruff/format, mypy (40 files), TypeScript, frontend build all passed
python tools/generate_sbom.py # 256 components
```
`test_compose_v2.py` creates its source and host-bind key fixture in pytest temporary
directories. The actual service-owned master key is generated only in an ephemeral
Compose named volume, then `down --volumes --remove-orphans` removes it. No fixture
secret or source directory is committed. The test runs migration, starts the stack,
checks readiness and metrics, stops/restarts the worker cleanly, restarts runtime
roles, verifies setup metadata persists, and tears down the project.
+13
View File
@@ -0,0 +1,13 @@
# M14 build provenance
- **Source revision:** `396219e776aa9a115900d2b7bfd9fb5c1cfde115`
- **Application base:** `python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7`
- **Frontend builder:** `node:22.17.1-alpine@sha256:5539840ce9d013fa13e3b9814c9353024be7ac75aca5db6d039504a56c04ea59`
- **Proxy base:** `nginx:1.29.7-alpine@sha256:e7257f1ef28ba17cf7c248cb8ccf6f0c6e0228ab9c315c152f9c203cd34cf6d1`
- **Build command:** `docker compose build --pull`
- **SBOM:** `docs/release/m14-sbom.json`, generated deterministically with
`python tools/generate_sbom.py` from the pinned backend manifest and frontend lockfile.
The build uses digest-pinned bases and a non-root runtime user. Provenance records
inputs and generation instructions rather than embedding a mutable image tag or a
secret-bearing build environment.

Some files were not shown because too many files have changed in this diff Show More