Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c94583307 | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 99097090e6 | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 6f35eb77ae | |||
| 684a11610a | |||
| 575e5837ea | |||
| 996ea73bbf | |||
| d0e5feeaa5 | |||
| 1f784b552d | |||
| 10cbe63095 | |||
| 4547105f3b | |||
| dacf105200 | |||
| 2525c58471 | |||
| 8dd350286e | |||
| 5af4de0d7e | |||
| ebfe991a15 | |||
| cc1507a33e | |||
| ae377baa74 | |||
| a0d4e76662 | |||
| b61c2256a8 | |||
| 7e6588a99d | |||
| 56b54e269a | |||
| 6e597b9e21 | |||
| a2c3d6877e | |||
| a200955ef2 | |||
| 76266fc3d0 | |||
| 9d54a2542b | |||
| 520078fd64 | |||
| 55c4dc1281 | |||
| 74ac7b00bb | |||
| ecc0acc8dd | |||
| fba01ddfb2 | |||
| 35a251a0b4 | |||
| b10eadf64b | |||
| 50fcf5c077 | |||
| 19db7db8d0 | |||
| 189f29ee41 | |||
| f2c3264be6 | |||
| f07a632c86 | |||
| 0b68efb6e0 | |||
| 54ec87a836 | |||
| 3d64ec9061 | |||
| ac6bd3304d | |||
| 6ec35988cc | |||
| e985f0122e | |||
| 2bd778117f | |||
| 23ae12e69c | |||
| 65d4fad3c5 | |||
| e6f64c39f3 | |||
| e7c42c17b9 | |||
| f7be50952a | |||
| 402e662c0c | |||
| 7450dd0ce5 | |||
| 7a88639250 | |||
| 1d33735e2f | |||
| d11b43b69f | |||
| 294d02f9fb | |||
| a10029f36c | |||
| 6a2ebae8c9 | |||
| 1a922d4171 | |||
| d906c12aa9 | |||
| 4906ce0cd9 | |||
| e7804c0f58 | |||
| b40eb3e88c | |||
| 98795e31dd | |||
| b4a7627718 | |||
| 9d27cd9fe8 | |||
| e3ef852eca | |||
| f824a1e6fe | |||
| ad09ffa6ec | |||
| 63ae706dd0 | |||
| 74b5d0dc8c | |||
| 25662e525c | |||
| d4e992a9e2 | |||
| 35ada0e662 | |||
| c3e2264771 | |||
| 76741d3ee6 | |||
| 62752a8390 | |||
| 461cb30b28 | |||
| 94aa88c154 | |||
| 4f695d7e62 | |||
| 7a48180dc3 | |||
| a0b0944709 | |||
| b79da51269 | |||
| 40a940304b | |||
| e344e961d6 | |||
| d6b3e8b804 | |||
| f997b3f7c5 | |||
| c795f8f873 | |||
| c52367401b | |||
| e9e0e87013 | |||
| 2b5331a1a0 | |||
| 6f41fa7cbe | |||
| cccc4a9d5a | |||
| 72b4a5e2bd | |||
| 8f1d2a149a | |||
| 7261c75bb2 | |||
| 84bf7e4aeb | |||
| 9373d93169 | |||
| 7fc8b82621 | |||
| 2d078c8b1e | |||
| 92c8dfe986 | |||
| 0bc0d99c21 | |||
| 965f6f6585 | |||
| a9f4657d03 | |||
| 875594d66d | |||
| 762e0de44c | |||
| 955dadc604 | |||
| 83f94b1f09 | |||
| 6807f449b7 | |||
| 92d0d5b891 | |||
| 834982d423 | |||
| 9873a8186a | |||
| d4b52668aa | |||
| d0191cd549 | |||
| 0ae0e3fec1 | |||
| 0926e4de83 | |||
| 8b70daed53 | |||
| ac6c97b6ce | |||
| a433c82488 | |||
| f8700fd7ed | |||
| 0fcfc745ff | |||
| d214ab82db | |||
| 753f1506b6 | |||
| 58bf30ed15 | |||
| c067c03662 | |||
| d273535950 | |||
| 7f97ba8e9b | |||
| caf73e39ba | |||
| 1e70462c2b | |||
| b9684b0107 | |||
| 716b8fa631 | |||
| d724a92d34 | |||
| 2ce7862058 | |||
| 285d3dace8 | |||
| 9fefe289a7 | |||
| 843683d579 | |||
| c1a4d2d9af | |||
| ea6c466c6c | |||
| 29c3563148 | |||
| 0509b9eb4a | |||
| 137757602f | |||
| 3cd8674c31 | |||
| 899fba9c9b | |||
| a2c787d474 | |||
| 8ba2b48967 | |||
| ec9b73225c | |||
| 0296ea5630 | |||
| 6dbd55a9ac | |||
| 27f4ecce86 | |||
| 7b72ccdc3c | |||
| 6b302b3279 | |||
| fb5725947d | |||
| 94254ee3fd | |||
| 9f7a750898 | |||
| 4e2edb1d93 | |||
| b179319601 | |||
| d185471802 | |||
| 4299c64922 | |||
| 3ccd94f661 | |||
| a441ea2fac | |||
| be81aa1c8b | |||
| da0b90ce33 | |||
| 1daac47951 | |||
| d38f953dfc | |||
| ea9f2e3c98 | |||
| 86245671e7 | |||
| fff3ea0c2f | |||
| b85ec38ff8 | |||
| c722cab86c | |||
| 8bae77e42c | |||
| 236130b0a7 | |||
| 9b04760423 | |||
| 75657bbcb0 | |||
| 577b052c05 | |||
| 56f440db1b | |||
| 71d9fe6406 | |||
| e7819bfc82 | |||
| 212d072417 | |||
| 082e8d03ff | |||
| 84038c25ec |
@@ -1,15 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
+43
-36
@@ -1,42 +1,49 @@
|
||||
# App identity
|
||||
APP_NAME=Headquarter
|
||||
ROOT_DOMAIN=localhost
|
||||
TOOL_DOMAIN=tools.localhost
|
||||
|
||||
# API / Web URLs
|
||||
API_URL=http://localhost:8000
|
||||
WEB_URL=http://localhost:5173
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# Database (local development)
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
# Database Configuration
|
||||
POSTGRES_USER=headquarter
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
POSTGRES_DB=headquarter
|
||||
# DATABASE_URL uses a literal value because Pydantic Settings does not expand
|
||||
# shell-style variable interpolation from .env files.
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/headquarter
|
||||
|
||||
# Authentik OIDC placeholders (wire in FN-004)
|
||||
AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/
|
||||
AUTHENTIK_CLIENT_ID=your-client-id
|
||||
AUTHENTIK_CLIENT_SECRET=your-client-secret
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Traefik / deployment placeholders (wire in FN-006)
|
||||
TRAEFIK_NETWORK=traefik
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||
TRAEFIK_LOG_LEVEL=INFO
|
||||
TRAEFIK_ACME_EMAIL=admin@example.com
|
||||
TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.localhost
|
||||
# Session Configuration
|
||||
SESSION_SECRET=change-me-in-production
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# Frontend build-time variables (passed to web container)
|
||||
VITE_API_URL=http://localhost:8000
|
||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/headquarter/
|
||||
VITE_OIDC_CLIENT_ID=your-client-id
|
||||
VITE_OIDC_REDIRECT_URI=https://headquarter.commumedia.org/callback
|
||||
# Application Configuration
|
||||
APP_ENV=development
|
||||
DEBUG=true
|
||||
LOG_LEVEL=info
|
||||
REPO_BASE_PATH=/data/repos
|
||||
|
||||
# Secrets (generate strong random values for production)
|
||||
SECRET_ENCRYPTION_KEY=change-me-in-production
|
||||
# Domain Configuration (for both development and traefik modes)
|
||||
API_DOMAIN=localhost
|
||||
WEB_DOMAIN=localhost
|
||||
AUTHENTIK_DOMAIN=authentik.local
|
||||
|
||||
# Auth dev bypass (local development only — NEVER enable in production)
|
||||
AUTH_DEV_BYPASS=false
|
||||
# Public URLs (optional - will be constructed from domains if not set)
|
||||
# API_PUBLIC_URL=https://api.example.com
|
||||
# WEB_PUBLIC_URL=https://app.example.com
|
||||
|
||||
# Authentik Configuration
|
||||
# Client ID: The OAuth client ID from Authentik (may be a UUID)
|
||||
AUTHENTIK_CLIENT_ID=headquarter-web
|
||||
AUTHENTIK_CLIENT_SECRET=change-me
|
||||
# Application Slug: The URL-friendly identifier used in Authentik URLs
|
||||
# This is often the same as the application identifier/slug in Authentik
|
||||
# e.g., if your Authentik app URL is /application/o/headquarter-web/, use "headquarter-web"
|
||||
AUTHENTIK_APPLICATION_SLUG=headquarter-web
|
||||
# Override Authentik URLs if they differ from the default pattern
|
||||
# AUTHENTIK_AUTHORIZE_URL=https://authentik.example.com/application/o/authorize/
|
||||
# AUTHENTIK_TOKEN_URL=https://authentik.example.com/application/o/token/
|
||||
|
||||
# Frontend Configuration
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
|
||||
# Docker Configuration
|
||||
COMPOSE_PROJECT_NAME=headquarter
|
||||
|
||||
# Traefik Configuration (for docker-compose.traefik.yml)
|
||||
# PROXY_WEB_NAME=headquarter-web
|
||||
# TRAEFIK_NETWORK=traefik
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
web-ci:
|
||||
name: Web CI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm --filter @headquarter/web lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm --filter @headquarter/web typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm --filter @headquarter/web test
|
||||
|
||||
api-ci:
|
||||
name: API CI
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: headquarter_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install API dev dependencies
|
||||
working-directory: apps/api
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
- name: Ruff check
|
||||
working-directory: apps/api
|
||||
run: ruff check app/ tests/
|
||||
|
||||
- name: Mypy
|
||||
working-directory: apps/api
|
||||
run: mypy app/ tests/
|
||||
|
||||
- name: Pytest
|
||||
working-directory: apps/api
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/headquarter_test
|
||||
run: pytest
|
||||
+38
-50
@@ -1,20 +1,45 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
# Beads / Dolt files (added by bd init)
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
|
||||
# Environment
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
.python-version
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# Python packaging
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
apps/web/dist/
|
||||
|
||||
# IDE / editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -23,40 +48,3 @@ build/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.egg-info/
|
||||
*.egg-info/
|
||||
dist/
|
||||
|
||||
# Docker volumes
|
||||
docker-volumes/
|
||||
|
||||
# Fusion internals
|
||||
.fusion/
|
||||
|
||||
# Misc
|
||||
.cache/
|
||||
.temp/
|
||||
tmp/
|
||||
.local-bin/
|
||||
|
||||
# OpenCode / Sisyphus
|
||||
.opencode/
|
||||
.sisyphus/
|
||||
AGENTS.md
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,153 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Core rule
|
||||
|
||||
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
||||
|
||||
## Priority order
|
||||
|
||||
1. Current user instruction
|
||||
2. OpenSpec proposal, tasks, and spec deltas
|
||||
3. This `AGENTS.md`
|
||||
4. Existing project conventions
|
||||
5. Agent assumptions
|
||||
|
||||
When instructions conflict, follow the higher-priority source. Do not silently expand scope.
|
||||
|
||||
## Default workflow
|
||||
|
||||
For any non-trivial change:
|
||||
|
||||
1. Read the relevant OpenSpec change, tasks, and spec deltas.
|
||||
2. Use `brainstorming` if scope, design, or requirements are unclear.
|
||||
3. Use `writing-plans` before implementation.
|
||||
4. Implement only the selected task or clearly requested change.
|
||||
5. Use tests, typecheck, lint, or targeted checks to verify.
|
||||
6. Use `verification-before-completion` before claiming completion.
|
||||
|
||||
If namespacing is required, use:
|
||||
|
||||
* `superpowers:brainstorming`
|
||||
* `superpowers:writing-plans`
|
||||
* `superpowers:test-driven-development`
|
||||
* `superpowers:systematic-debugging`
|
||||
* `superpowers:verification-before-completion`
|
||||
|
||||
## When OpenSpec is required
|
||||
|
||||
Create or update an OpenSpec change before implementing:
|
||||
|
||||
* New features
|
||||
* Behavior changes
|
||||
* API changes
|
||||
* Database/schema changes
|
||||
* Auth, security, billing, permissions, or data handling changes
|
||||
* Architecture changes
|
||||
* Large refactors
|
||||
* Anything with unclear acceptance criteria
|
||||
|
||||
Small local fixes may skip OpenSpec if they do not change behavior or public contracts.
|
||||
|
||||
## Superpowers usage
|
||||
|
||||
Use:
|
||||
|
||||
* `brainstorming` for ambiguity, design choices, or scope questions.
|
||||
* `writing-plans` for multi-step or multi-file work.
|
||||
* `test-driven-development` for behavior changes and bug fixes where practical.
|
||||
* `systematic-debugging` for failing tests or unclear bugs.
|
||||
* `verification-before-completion` before final completion claims.
|
||||
* `using-git-worktrees` only for isolated risky or parallel work.
|
||||
* `dispatching-parallel-agents` only for independent subtasks with clear boundaries.
|
||||
|
||||
If a skill is unavailable, follow its intent manually and say so.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
Do not:
|
||||
|
||||
* Implement outside the selected OpenSpec task.
|
||||
* Mix unrelated cleanup with feature work.
|
||||
* Introduce new dependencies without clear justification.
|
||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||
* Decide product behavior silently when the spec is unclear.
|
||||
|
||||
If scope must change, propose an OpenSpec update first.
|
||||
|
||||
## Verification
|
||||
|
||||
Before completion, report:
|
||||
|
||||
* What changed
|
||||
* Which OpenSpec task/change it addresses
|
||||
* Tests/checks run
|
||||
* Any failures, skipped checks, assumptions, or risks
|
||||
|
||||
Do not claim completion without verification evidence.
|
||||
|
||||
## Git workflow
|
||||
|
||||
### Branching strategy
|
||||
|
||||
For every spec change or new functionality:
|
||||
|
||||
1. Create a new branch from `dev` with a proper prefix:
|
||||
- `feat/` for new features (e.g., `feat/tool-workshop`)
|
||||
- `fix/` for bug fixes (e.g., `fix/terminal-tty`)
|
||||
- `refactor/` for refactors (e.g., `refactor/api-cleanup`)
|
||||
- `docs/` for documentation (e.g., `docs/api-guide`)
|
||||
- `chore/` for maintenance (e.g., `chore/update-deps`)
|
||||
2. Branch name should reference the OpenSpec change name when applicable.
|
||||
3. Do not commit directly to `main` or `dev`.
|
||||
|
||||
### Completion and merge
|
||||
|
||||
When implementation is complete and verified:
|
||||
|
||||
1. Ensure all tests pass and quality gates are met.
|
||||
2. Stage all changes with `git add -A`.
|
||||
3. Create a commit with a proper conventional commit message (see below).
|
||||
4. Switch to `dev`: `git checkout dev`.
|
||||
5. Merge the feature branch: `git merge --no-ff <branch-name>`.
|
||||
6. Push to remote: `git push origin dev`.
|
||||
7. Delete the local feature branch if desired: `git branch -d <branch-name>`.
|
||||
|
||||
### Auto-commit on spec completion
|
||||
|
||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||
|
||||
1. Stage all changes with `git add -A`
|
||||
2. Create a commit with a proper conventional commit message
|
||||
3. The commit message should:
|
||||
- Use conventional commit format (`feat:`, `fix:`, `refactor:`, etc.)
|
||||
- Reference the OpenSpec change name and relevant user stories
|
||||
- Include a brief summary of what changed
|
||||
- Mention quality gate results (tests passed, etc.)
|
||||
- Example:
|
||||
```
|
||||
feat: implement user profile management
|
||||
|
||||
- Add authenticated profile endpoints (GET/PUT /users/me)
|
||||
- Add avatar upload with file validation
|
||||
- Create frontend profile page
|
||||
|
||||
Quality gates: pytest (50 passed), ruff, mypy
|
||||
```
|
||||
|
||||
### Commit scope
|
||||
|
||||
- One commit per completed OpenSpec change (or related group of changes)
|
||||
- Do not commit untested or broken code
|
||||
- Do not commit secrets, .env files, or credentials
|
||||
|
||||
## Definition of done
|
||||
|
||||
A task is done when:
|
||||
|
||||
* It matches OpenSpec.
|
||||
* The diff is focused.
|
||||
* Relevant tests/checks passed or limitations are stated.
|
||||
* No unrelated scope was added.
|
||||
* Remaining risks or follow-ups are documented.
|
||||
* Changes are committed with a proper conventional commit message.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Project Management** - Create and manage projects with dashboard view
|
||||
- **Git Repository Management** - Bare repository initialization and mirror cloning with smart URL parsing
|
||||
- **Repository Workspace** - File browser with syntax highlighting, branch switching, and file editing
|
||||
- **Git History Visualization** - Commit history with graph visualization and diff viewing
|
||||
- **Git Control** - Branch management, commit, fetch/pull/push, merge operations
|
||||
- **File Editor** - Syntax highlighting for 50+ languages with edit/commit workflow
|
||||
- **Smart Git URL Parsing** - Automatic detection and correction of browser URLs to git clone URLs
|
||||
- **OAuth2 Authentication** - Session-based authentication via Authentik with simplified flow
|
||||
- **User Profile** - Profile management with avatar upload
|
||||
- **User Settings** - Theme selection, git identity, and preference management
|
||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified authentication from JWT to session-based cookies
|
||||
- Restructured test infrastructure with unit/integration/system separation
|
||||
- Improved Docker deployment with Traefik integration
|
||||
|
||||
### Fixed
|
||||
|
||||
- Database migration chain errors
|
||||
- Cross-origin cookie handling for OAuth flow
|
||||
- Nginx permission issues in container
|
||||
|
||||
## [0.1.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release with core project and repository management
|
||||
- OAuth2 authentication with Authentik
|
||||
- Basic file browsing and git history viewing
|
||||
- Development tool type definitions
|
||||
@@ -0,0 +1,114 @@
|
||||
.PHONY: help up down logs migrate test test-unit test-integration test-system test-e2e lint clean build
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Headquarter Development Commands"
|
||||
@echo "================================"
|
||||
@echo "make up - Start all services"
|
||||
@echo "make down - Stop all services"
|
||||
@echo "make logs - View service logs"
|
||||
@echo "make migrate - Run database migrations"
|
||||
@echo "make test - Run all test suites"
|
||||
@echo "make test-unit - Run unit tests only"
|
||||
@echo "make test-integration - Run integration tests only"
|
||||
@echo "make test-system - Run system tests only"
|
||||
@echo "make test-e2e - Run E2E tests (Playwright)"
|
||||
@echo "make lint - Run linting"
|
||||
@echo "make build - Build all Docker images"
|
||||
@echo "make clean - Remove containers and volumes"
|
||||
@echo "make shell - Open shell in API container"
|
||||
|
||||
# Start services
|
||||
up:
|
||||
docker compose up -d
|
||||
@echo "Services starting..."
|
||||
@echo "API: http://localhost:8000"
|
||||
@echo "Web: http://localhost:3000"
|
||||
@echo "Postgres: localhost:5432"
|
||||
@echo "Redis: localhost:6379"
|
||||
|
||||
# Stop services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# View logs
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
logs-api:
|
||||
docker compose logs -f api
|
||||
|
||||
logs-web:
|
||||
docker compose logs -f web
|
||||
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Run database migrations
|
||||
migrate:
|
||||
docker compose exec api alembic upgrade head
|
||||
|
||||
# Create new migration
|
||||
migration:
|
||||
docker compose exec api alembic revision --autogenerate -m "$(message)"
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
docker compose exec api pytest -v
|
||||
|
||||
# Run unit tests only (fast, no external dependencies)
|
||||
test-unit:
|
||||
docker compose exec api pytest -v -m unit tests/unit/
|
||||
|
||||
# Run integration tests only (requires database)
|
||||
test-integration:
|
||||
docker compose exec api pytest -v -m integration tests/integration/
|
||||
|
||||
# Run system tests only (full stack)
|
||||
test-system:
|
||||
docker compose exec api pytest -v -m system tests/system/
|
||||
|
||||
# Run E2E tests (requires full application stack)
|
||||
test-e2e:
|
||||
cd e2e && npx playwright test
|
||||
|
||||
# Run linting
|
||||
lint:
|
||||
docker compose exec api ruff check .
|
||||
docker compose exec api mypy .
|
||||
cd apps/web && npm run lint
|
||||
|
||||
# Type checking
|
||||
typecheck:
|
||||
docker compose exec api mypy .
|
||||
cd apps/web && npm run typecheck
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Build specific service
|
||||
build-api:
|
||||
docker compose build api
|
||||
|
||||
build-web:
|
||||
docker compose build web
|
||||
|
||||
# Clean up
|
||||
clean:
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Open shell in API container
|
||||
shell:
|
||||
docker compose exec api /bin/sh
|
||||
|
||||
# Database shell
|
||||
db-shell:
|
||||
docker compose exec postgres psql -U $(POSTGRES_USER) -d $(POSTGRES_DB)
|
||||
|
||||
# Health check
|
||||
health:
|
||||
@echo "Checking service health..."
|
||||
@docker compose ps
|
||||
@@ -1,137 +1,195 @@
|
||||
# Headquarter
|
||||
|
||||
Hosted workspace and tool-orchestration platform where authenticated users create projects, connect Git repositories, and spawn self-hosted tools such as OpenCode and code-server.
|
||||
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
|
||||
|
||||
## Current Status
|
||||
## Overview
|
||||
|
||||
This repository provides:
|
||||
Headquarter provides a centralized workspace for development teams to:
|
||||
- Manage projects and their associated git repositories
|
||||
- Browse repository files and view git history
|
||||
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
|
||||
- Manage SSH keys and user preferences
|
||||
|
||||
- React + Vite + TypeScript frontend (`apps/web`)
|
||||
- FastAPI + Python backend (`apps/api`)
|
||||
- Manifest-driven tool registry with built-in OpenCode and code-server definitions
|
||||
- Root monorepo tooling (pnpm workspace, Makefile)
|
||||
- Docker Compose local development stack
|
||||
- Deployment skeleton for Portainer + Traefik
|
||||
- Automated tests (Vitest + pytest)
|
||||
## Features
|
||||
|
||||
## Repository Layout
|
||||
### Project Management
|
||||
- Create and manage projects
|
||||
- View all projects in a dashboard
|
||||
- Click any project to open its workspace
|
||||
|
||||
```text
|
||||
├── apps/
|
||||
│ ├── web/ # React frontend
|
||||
│ └── api/ # FastAPI backend
|
||||
├── packages/ # Shared packages (future)
|
||||
├── docs/ # Architecture, development, and deployment docs
|
||||
├── deploy/ # Portainer/Traefik deployment examples
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.traefik.yml
|
||||
├── package.json # Root monorepo scripts
|
||||
├── Makefile # Common local workflows
|
||||
└── .env.example # Shared environment variables
|
||||
```
|
||||
### Git Repository Management
|
||||
- Initialize bare repositories
|
||||
- Clone repositories (including mirror clones)
|
||||
- Smart URL parsing (converts browser URLs to git URLs)
|
||||
- View repository history and commit details
|
||||
|
||||
## Prerequisites
|
||||
### Repository Workspace
|
||||
- Browse files and directories
|
||||
- View file contents with syntax highlighting
|
||||
- Switch between branches
|
||||
- Quick file editing with automatic commits
|
||||
|
||||
- Node.js ≥ 20 and pnpm ≥ 9
|
||||
- Python ≥ 3.11
|
||||
- Docker and Docker Compose (optional, for local Postgres)
|
||||
### Git History Visualization
|
||||
- View commit history with branch graph
|
||||
- See commit details, statistics, and diffs
|
||||
- Filter by branch
|
||||
|
||||
## Quickstart
|
||||
### Authentication
|
||||
- OAuth2 via Authentik
|
||||
- Session-based authentication
|
||||
- User profile management
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
make install
|
||||
### Tool Management
|
||||
- Built-in tool types (code-server, jupyter-notebook)
|
||||
- Create custom tool types with Docker Compose templates
|
||||
- Template validation
|
||||
|
||||
# Copy environment examples
|
||||
cp .env.example .env
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
### User Settings
|
||||
- Theme selection (system/light/dark)
|
||||
- Git identity configuration
|
||||
- Default editor preference
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
### SSH Key Management
|
||||
- Generate Ed25519 key pairs
|
||||
- Copy public keys to clipboard
|
||||
- Delete keys
|
||||
|
||||
# Start frontend and backend in development mode
|
||||
make dev
|
||||
```
|
||||
## Quick Start
|
||||
|
||||
### Docker Compose
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Git
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
### Local Development
|
||||
|
||||
This starts the API, web frontend, and PostgreSQL.
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd headquarter
|
||||
```
|
||||
|
||||
## Commands
|
||||
2. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make install` | Install Node and Python dependencies |
|
||||
| `make dev` | Start frontend and backend in parallel |
|
||||
| `make test` | Run frontend and backend tests |
|
||||
| `make lint` | Run linters |
|
||||
| `make typecheck` | Run type checkers |
|
||||
| `make build` | Build frontend and backend |
|
||||
| `make compose-up` | Start Docker Compose stack |
|
||||
| `make compose-down` | Stop Docker Compose stack |
|
||||
3. **Start services:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
4. **Access the application:**
|
||||
- Frontend: http://localhost:5173
|
||||
- API: http://localhost:8000
|
||||
- API Docs: http://localhost:8000/docs
|
||||
|
||||
All pull requests and pushes to `main` are validated by a GitHub Actions workflow (`.github/workflows/ci.yml`). The workflow runs the frontend and backend quality gates in parallel:
|
||||
### Production Deployment
|
||||
|
||||
- **Web CI** — lint, typecheck, and test the React frontend.
|
||||
- **API CI** — lint with `ruff`, typecheck with `mypy`, and run `pytest` against a PostgreSQL service container.
|
||||
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
|
||||
|
||||
See [Development](docs/development.md) for details on running these checks locally.
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- **FastAPI** - Python web framework
|
||||
- **SQLAlchemy** - ORM with async PostgreSQL support
|
||||
- **Pydantic** - Data validation
|
||||
- **Alembic** - Database migrations
|
||||
- **python-jose** - JWT handling
|
||||
|
||||
### Frontend
|
||||
- **React** - UI library
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool
|
||||
- **React Router** - Client-side routing
|
||||
|
||||
### Infrastructure
|
||||
- **Docker** - Containerization
|
||||
- **PostgreSQL** - Database
|
||||
- **Traefik** - Reverse proxy (production)
|
||||
- **Authentik** - Identity provider
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) — System design and MVP phases
|
||||
- [Development](docs/development.md) — Local setup and day-to-day commands
|
||||
- [Deployment](docs/deployment.md) — Portainer/Traefik assumptions
|
||||
- [User Guide](docs/features/) - Feature documentation
|
||||
- [API Reference](docs/api/) - API endpoints
|
||||
- [Architecture](docs/architecture/) - System design
|
||||
- [Deployment](docs/deployment/) - Setup guides
|
||||
- [Development](docs/development/) - Contributing
|
||||
|
||||
## Frontend Environment Variables
|
||||
## Project Structure
|
||||
|
||||
The frontend (`apps/web`) requires these environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `VITE_API_URL` | Backend API base URL |
|
||||
| `VITE_OIDC_ISSUER` | OIDC provider issuer URL |
|
||||
| `VITE_OIDC_CLIENT_ID` | OIDC client ID |
|
||||
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL |
|
||||
|
||||
Copy `apps/web/.env.example` to `apps/web/.env` and fill in your values.
|
||||
|
||||
## Deployment
|
||||
|
||||
Deploy to production using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Copy and configure production environment
|
||||
cp deploy/.env.example deploy/.env
|
||||
# Edit deploy/.env with your domain and secrets
|
||||
|
||||
# Deploy locally for testing
|
||||
docker compose -f docker-compose.prod.yml up --build -d
|
||||
|
||||
# Or deploy via Portainer using deploy/portainer-stack.yml
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── api/ # FastAPI backend
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── api/ # API routes
|
||||
│ │ │ ├── auth/ # Authentication
|
||||
│ │ │ ├── models/ # Database models
|
||||
│ │ │ └── utils/ # Utilities
|
||||
│ │ ├── tests/ # Test suite
|
||||
│ │ └── Dockerfile
|
||||
│ └── web/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API clients
|
||||
│ │ ├── components/# UI components
|
||||
│ │ └── pages/ # Page components
|
||||
│ └── Dockerfile
|
||||
├── docs/ # Documentation
|
||||
├── docker-compose.yml # Development setup
|
||||
├── docker-compose.traefik.yml # Production setup
|
||||
└── Makefile # Common commands
|
||||
```
|
||||
|
||||
See [Deployment Guide](docs/deployment.md) for full details.
|
||||
## Development
|
||||
|
||||
## Scope Boundaries
|
||||
### Backend Development
|
||||
```bash
|
||||
cd apps/api
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
uvicorn src.main:app --reload
|
||||
```
|
||||
|
||||
This scaffold intentionally defers detailed implementation to follow-up tasks:
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd apps/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- **FN-004** — Backend domain models, database migrations, API endpoints, auth integration
|
||||
- **FN-005** — Frontend dashboard navigation, project creation, authenticated flows
|
||||
- **FN-006** — Full deployment automation, dynamic Traefik labels for spawned tool containers
|
||||
- **FN-003** — Manifest-driven tool registry
|
||||
- **FN-007** — Provider-independent Git connection model
|
||||
- **FN-008** — OpenCode terminal environment proof of concept
|
||||
- **FN-009** — Persistent config and secrets handling
|
||||
- **FN-010** — code-server manifest and spawn flow
|
||||
### Running Tests
|
||||
```bash
|
||||
# Backend tests
|
||||
make test
|
||||
|
||||
# Frontend tests
|
||||
make test-web
|
||||
|
||||
# All quality gates
|
||||
make lint
|
||||
make typecheck
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Key environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `API_DOMAIN` | API domain | `localhost` |
|
||||
| `WEB_DOMAIN` | Web domain | `localhost` |
|
||||
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
|
||||
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
|
||||
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
|
||||
| `DATABASE_URL` | PostgreSQL URL | - |
|
||||
| `JWT_SECRET` | JWT signing secret | - |
|
||||
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
|
||||
|
||||
See [Environment Variables](docs/deployment/environment.md) for complete list.
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
[License information]
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.git/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
+64
-9
@@ -1,18 +1,73 @@
|
||||
FROM python:3.12-slim
|
||||
# Build stage
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir --user -e ".[dev]"
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user and add to docker group
|
||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
|
||||
&& groupadd -r docker || true \
|
||||
&& usermod -aG docker appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
# Install runtime dependencies including Docker CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
git \
|
||||
netcat-openbsd \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||
&& echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
|
||||
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
|
||||
&& curl -L --output /usr/local/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
|
||||
&& chmod +x /usr/local/bin/cloudflared \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
|
||||
# Copy dependencies from builder
|
||||
COPY --from=builder /root/.local /root/.local
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY pyproject.toml ./
|
||||
RUN pip install --no-cache-dir -e "."
|
||||
# Copy application code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
USER appuser
|
||||
# Create directories for repo and instance storage
|
||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||
|
||||
# NOTE: Running as root to access Docker socket for managing tool instances
|
||||
# This is required because Docker socket permissions require root or docker group membership
|
||||
# which doesn't work well across container boundaries.
|
||||
# Consider using Docker-in-Docker or rootless Docker for production hardening.
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run the application (with database wait)
|
||||
ENTRYPOINT ["/usr/local/bin/wait-for-db.sh"]
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
.PHONY: revision upgrade downgrade lint test typecheck
|
||||
|
||||
revision:
|
||||
.venv/bin/alembic revision --autogenerate -m "$(msg)"
|
||||
|
||||
upgrade:
|
||||
.venv/bin/alembic upgrade head
|
||||
|
||||
downgrade:
|
||||
.venv/bin/alembic downgrade -1
|
||||
|
||||
lint:
|
||||
.venv/bin/ruff check app tests
|
||||
|
||||
test:
|
||||
.venv/bin/pytest
|
||||
|
||||
typecheck:
|
||||
.venv/bin/mypy app tests
|
||||
@@ -0,0 +1,252 @@
|
||||
# Headquarter API
|
||||
|
||||
The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools.
|
||||
|
||||
## Overview
|
||||
|
||||
Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: FastAPI (Python 3.12+)
|
||||
- **Database**: PostgreSQL 15+ with asyncpg
|
||||
- **ORM**: SQLAlchemy 2.0 (async)
|
||||
- **Auth**: OAuth2 via Authentik with session cookies
|
||||
- **Migrations**: Alembic
|
||||
- **Tools**: Docker Compose for instance management
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 15+ running locally
|
||||
- Docker (for tool instances)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Set up database
|
||||
# Ensure PostgreSQL is running with a 'headquarter' database
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Start development server
|
||||
uvicorn src.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`.
|
||||
|
||||
### Interactive Documentation
|
||||
|
||||
Once running, visit:
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
- **OpenAPI JSON**: http://localhost:8000/openapi.json
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `DATABASE_URL` | Yes | - | PostgreSQL connection string |
|
||||
| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) |
|
||||
| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain |
|
||||
| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID |
|
||||
| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret |
|
||||
| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug |
|
||||
| `WEB_BASE_URL` | Yes | - | Public frontend URL |
|
||||
| `SESSION_SECRET` | Yes | - | Secret for session cookie signing |
|
||||
| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) |
|
||||
| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads |
|
||||
| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories |
|
||||
| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level |
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run specific test category
|
||||
pytest -m unit # Unit tests (no DB)
|
||||
pytest -m integration # Integration tests (requires DB)
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
ruff format src tests
|
||||
|
||||
# Lint
|
||||
ruff check src tests
|
||||
|
||||
# Type check
|
||||
mypy src
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
# Create new migration
|
||||
alembic revision --autogenerate -m "description"
|
||||
|
||||
# Apply migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback one migration
|
||||
alembic downgrade -1
|
||||
|
||||
# Show current revision
|
||||
alembic current
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API endpoint routers
|
||||
│ ├── auth.py # OAuth2 authentication
|
||||
│ ├── dashboard.py # Dashboard summary
|
||||
│ ├── git_repositories.py # Git repo management
|
||||
│ ├── health.py # Health checks
|
||||
│ ├── projects.py # Project CRUD
|
||||
│ ├── ssh_keys.py # SSH key management
|
||||
│ ├── terminal.py # WebSocket terminal
|
||||
│ ├── tool_instances.py # Tool instance management
|
||||
│ ├── tool_types.py # Tool type definitions
|
||||
│ ├── user_config.py # User preferences
|
||||
│ └── users.py # User profile
|
||||
├── auth/ # Authentication logic
|
||||
│ ├── cookies.py # Cookie utilities
|
||||
│ ├── dependencies.py # Auth dependencies
|
||||
│ ├── oidc.py # OpenID Connect
|
||||
│ └── session.py # Session management
|
||||
├── config.py # Application settings
|
||||
├── database.py # Database setup
|
||||
├── main.py # FastAPI application
|
||||
├── models/ # SQLAlchemy models
|
||||
├── schemas/ # Pydantic schemas
|
||||
├── services/ # Business logic
|
||||
│ ├── docker.py # Docker Compose management
|
||||
│ ├── terminal_manager.py # Terminal sessions
|
||||
│ └── terminal_session.py # Terminal I/O
|
||||
└── utils/ # Utilities
|
||||
├── git_control.py # Git operations
|
||||
├── git_files.py # File operations
|
||||
├── git_history.py # History extraction
|
||||
└── git_url_parser.py # URL parsing
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. User clicks "Login" → redirects to Authentik OAuth
|
||||
2. Authentik redirects back with authorization code
|
||||
3. API exchanges code for tokens and fetches user info
|
||||
4. API creates session cookie (HMAC-signed, httpOnly)
|
||||
5. Frontend stores nothing - cookie sent automatically
|
||||
6. Subsequent requests include cookie for authentication
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Client → FastAPI Router → Auth Dependency → Service Layer → Database
|
||||
↓
|
||||
Pydantic Models (validation)
|
||||
↓
|
||||
SQLAlchemy Models (ORM)
|
||||
↓
|
||||
PostgreSQL (storage)
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `GET /auth/login` - Initiate OAuth login
|
||||
- `GET /auth/callback` - OAuth callback
|
||||
- `GET /auth/me` - Get current user
|
||||
- `POST /auth/logout` - Logout
|
||||
|
||||
### Projects
|
||||
- `GET /projects` - List projects
|
||||
- `POST /projects` - Create project
|
||||
- `GET /projects/{id}` - Get project
|
||||
- `PUT /projects/{id}` - Update project
|
||||
- `DELETE /projects/{id}` - Delete project
|
||||
|
||||
### Git Repositories
|
||||
- `GET /projects/{id}/repositories` - List repositories
|
||||
- `POST /projects/{id}/repositories` - Create repository
|
||||
- `GET /projects/{id}/repositories/{id}` - Get repository
|
||||
- `DELETE /projects/{id}/repositories/{id}` - Delete repository
|
||||
- `GET /projects/{id}/repositories/{id}/files` - List files
|
||||
- `GET /projects/{id}/repositories/{id}/files/content` - Get file content
|
||||
- `POST /projects/{id}/repositories/{id}/files/content` - Update file
|
||||
- `GET /projects/{id}/repositories/{id}/branches` - List branches
|
||||
- `GET /projects/{id}/repositories/{id}/history` - Commit history
|
||||
- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail
|
||||
|
||||
### Tool Types
|
||||
- `GET /tool-types` - List tool types
|
||||
- `POST /tool-types` - Create tool type
|
||||
- `GET /tool-types/{id}` - Get tool type
|
||||
- `PUT /tool-types/{id}` - Update tool type
|
||||
- `DELETE /tool-types/{id}` - Delete tool type
|
||||
|
||||
### Tool Instances
|
||||
- `GET /tool-instances` - List instances
|
||||
- `POST /tool-instances` - Create instance
|
||||
- `GET /tool-instances/{id}` - Get instance
|
||||
- `POST /tool-instances/{id}/start` - Start instance
|
||||
- `POST /tool-instances/{id}/stop` - Stop instance
|
||||
- `POST /tool-instances/{id}/restart` - Restart instance
|
||||
- `DELETE /tool-instances/{id}` - Delete instance
|
||||
- `GET /tool-instances/{id}/logs` - Get logs
|
||||
|
||||
### Terminal
|
||||
- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal
|
||||
|
||||
### Users
|
||||
- `GET /users/me` - Get profile
|
||||
- `PUT /users/me` - Update profile
|
||||
- `POST /users/me/avatar` - Upload avatar
|
||||
- `GET /users/me/config` - Get config
|
||||
- `PATCH /users/me/config` - Update config
|
||||
|
||||
### SSH Keys
|
||||
- `GET /ssh-keys` - List keys
|
||||
- `POST /ssh-keys` - Create key
|
||||
- `DELETE /ssh-keys/{id}` - Delete key
|
||||
|
||||
### Health
|
||||
- `GET /health` - System health
|
||||
- `GET /health/db` - Database health
|
||||
|
||||
## Deployment
|
||||
|
||||
See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Follow PEP 8 style guide
|
||||
2. Add tests for new endpoints
|
||||
3. Update documentation
|
||||
4. Run quality gates before committing
|
||||
+3
-116
@@ -1,119 +1,8 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://headquarter:headquarter@postgres:5432/headquarter
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql+asyncpg://
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -124,12 +13,11 @@ keys = console
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
@@ -146,4 +34,3 @@ formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
+18
-29
@@ -1,44 +1,32 @@
|
||||
import asyncio
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from app.config import settings
|
||||
from app.models import Base
|
||||
from src.config import Settings
|
||||
from src.models import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
settings = Settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Build async URL from settings
|
||||
database_url = settings.database_url
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@@ -46,18 +34,13 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
@@ -70,7 +53,13 @@ async def run_migrations_online() -> None:
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
import asyncio
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
run_migrations_online()
|
||||
|
||||
@@ -3,26 +3,23 @@
|
||||
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 identifiers, used by Alembic.
|
||||
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)}
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial_schema
|
||||
Revises:
|
||||
Create Date: 2026-05-17 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0001_initial_schema"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAMES = [
|
||||
"users",
|
||||
"ssh_keys",
|
||||
"projects",
|
||||
"git_repositories",
|
||||
"user_configs",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("authentik_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("avatar_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("authentik_id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_authentik_id"), "users", ["authentik_id"], unique=True)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
|
||||
op.create_table(
|
||||
"ssh_keys",
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("public_key", sa.Text(), nullable=False),
|
||||
sa.Column("private_key_encrypted", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"projects",
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("default_ssh_key_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["default_ssh_key_id"], ["ssh_keys.id"]),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"git_repositories",
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("path", sa.String(length=1024), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("is_mirror", sa.Boolean(), nullable=False),
|
||||
sa.Column("remote_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("last_push", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"user_configs",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("user_id"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("user_configs")
|
||||
op.drop_table("git_repositories")
|
||||
op.drop_table("projects")
|
||||
op.drop_table("ssh_keys")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_authentik_id"), table_name="users")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add refresh tokens table
|
||||
|
||||
Revision ID: 0002_refresh_tokens
|
||||
Revises: 0001_initial_schema
|
||||
Create Date: 2026-05-17 00:00:01.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "0002_refresh_tokens"
|
||||
down_revision = "0001_initial_schema"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
inspector = sa.inspect(connection)
|
||||
|
||||
if not inspector.has_table("refresh_tokens"):
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("user_agent", sa.String(length=512), nullable=True),
|
||||
sa.Column("ip_address", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
|
||||
existing_indexes = {index["name"] for index in inspector.get_indexes("refresh_tokens")}
|
||||
user_index = op.f("ix_refresh_tokens_user_id")
|
||||
expires_index = op.f("ix_refresh_tokens_expires_at")
|
||||
if user_index not in existing_indexes:
|
||||
op.create_index(user_index, "refresh_tokens", ["user_id"], unique=False)
|
||||
if expires_index not in existing_indexes:
|
||||
op.create_index(expires_index, "refresh_tokens", ["expires_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
inspector = sa.inspect(connection)
|
||||
if inspector.has_table("refresh_tokens"):
|
||||
existing_indexes = {index["name"] for index in inspector.get_indexes("refresh_tokens")}
|
||||
expires_index = op.f("ix_refresh_tokens_expires_at")
|
||||
user_index = op.f("ix_refresh_tokens_user_id")
|
||||
if expires_index in existing_indexes:
|
||||
op.drop_index(expires_index, table_name="refresh_tokens")
|
||||
if user_index in existing_indexes:
|
||||
op.drop_index(user_index, table_name="refresh_tokens")
|
||||
op.drop_table("refresh_tokens")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add user_configs table
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2025-05-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0003_user_configs'
|
||||
down_revision: Union[str, None] = '0002_refresh_tokens'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'user_configs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('config', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id'),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('user_configs')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add tool_types table
|
||||
|
||||
Revision ID: 0004_tool_types
|
||||
Revises: 0003_user_configs
|
||||
Create Date: 2026-05-18 15:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0004_tool_types"
|
||||
down_revision: Union[str, None] = "0003_user_configs"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tool_types",
|
||||
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False, unique=True),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("compose_template", sa.Text(), nullable=False),
|
||||
sa.Column("required_variables", sa.JSON(), nullable=False, default=list),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, default=False),
|
||||
sa.Column("created_by_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
onupdate=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("tool_types")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""add timestamps to ssh_keys table
|
||||
|
||||
Revision ID: 0005_ssh_keys_timestamps
|
||||
Revises: 0004_tool_types
|
||||
Create Date: 2026-05-19 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0005_ssh_keys_timestamps"
|
||||
down_revision: Union[str, None] = "0004_tool_types"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ssh_keys",
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"ssh_keys",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ssh_keys", "updated_at")
|
||||
op.drop_column("ssh_keys", "created_at")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add tool_instances table
|
||||
|
||||
Revision ID: 0006_tool_instances
|
||||
Revises: 0005_ssh_keys_timestamps
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0006_tool_instances"
|
||||
down_revision: Union[str, None] = "0005_ssh_keys_timestamps"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tool_instances",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("repository_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("status", sa.String(50), nullable=False, server_default="pending"),
|
||||
sa.Column("container_id", sa.String(255), nullable=True),
|
||||
sa.Column("compose_path", sa.String(1024), nullable=True),
|
||||
sa.Column("url", sa.String(1024), nullable=True),
|
||||
sa.Column("port", sa.Integer(), nullable=True),
|
||||
sa.Column("last_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||
sa.ForeignKeyConstraint(["repository_id"], ["git_repositories.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_tool_instances_owner", "tool_instances", ["owner_id"])
|
||||
op.create_index("idx_tool_instances_repo", "tool_instances", ["repository_id"])
|
||||
op.create_index("idx_tool_instances_status", "tool_instances", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_tool_instances_status", table_name="tool_instances")
|
||||
op.drop_index("idx_tool_instances_repo", table_name="tool_instances")
|
||||
op.drop_index("idx_tool_instances_owner", table_name="tool_instances")
|
||||
op.drop_table("tool_instances")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add container_name to tool_instances
|
||||
|
||||
Revision ID: 0007_instance_container_name
|
||||
Revises: 0006_tool_instances
|
||||
Create Date: 2026-05-20 08:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0007_instance_container_name"
|
||||
down_revision: Union[str, None] = "0006_tool_instances"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("container_name", sa.String(255), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_instances", "container_name")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add category and interfaces to tool_types
|
||||
|
||||
Revision ID: 0008_tool_type_category
|
||||
Revises: 0007_instance_container_name
|
||||
Create Date: 2026-05-20 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0008_tool_type_category"
|
||||
down_revision: Union[str, None] = "0007_instance_container_name"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_types",
|
||||
sa.Column("category", sa.String(50), nullable=False, server_default="other")
|
||||
)
|
||||
op.add_column(
|
||||
"tool_types",
|
||||
sa.Column("interfaces", sa.JSON(), nullable=False, server_default='["web"]')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_types", "interfaces")
|
||||
op.drop_column("tool_types", "category")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add tool_configs table
|
||||
|
||||
Revision ID: 0009_tool_configs
|
||||
Revises: 0008_tool_type_category
|
||||
Create Date: 2026-05-20 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0009_tool_configs"
|
||||
down_revision: Union[str, None] = "0008_tool_type_category"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tool_configs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("key", sa.String(255), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("config_type", sa.String(20), nullable=False, server_default="env"),
|
||||
sa.Column("file_path", sa.String(1024), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["tool_type_id"], ["tool_types.id"]),
|
||||
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_tool_configs_user_tool", "tool_configs", ["user_id", "tool_type_id"])
|
||||
op.create_index("idx_tool_configs_project", "tool_configs", ["project_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_tool_configs_project", table_name="tool_configs")
|
||||
op.drop_index("idx_tool_configs_user_tool", table_name="tool_configs")
|
||||
op.drop_table("tool_configs")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add default_port to tool_types
|
||||
|
||||
Revision ID: 0010_tool_type_default_port
|
||||
Revises: 0009_tool_configs
|
||||
Create Date: 2026-05-20 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0010_tool_type_default_port"
|
||||
down_revision: Union[str, None] = "0009_tool_configs"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_types",
|
||||
sa.Column("default_port", sa.Integer(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_types", "default_port")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add tunnel fields to tool_instances
|
||||
|
||||
Revision ID: 0011_tool_instance_tunnel_fields
|
||||
Revises: 0010_tool_type_default_port
|
||||
Create Date: 2026-05-20 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0011_tool_instance_tunnel_fields"
|
||||
down_revision: Union[str, None] = "0010_tool_type_default_port"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("public_url", sa.String(1024), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"tool_instances",
|
||||
sa.Column("tunnel_id", sa.String(255), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tool_instances", "tunnel_id")
|
||||
op.drop_column("tool_instances", "public_url")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""make default_port non-nullable and set values
|
||||
|
||||
Revision ID: 0012_default_port_req
|
||||
Revises: 0011_tool_instance_tunnel_fields
|
||||
Create Date: 2026-05-20 15:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0012_default_port_req"
|
||||
down_revision: Union[str, None] = "0011_tool_instance_tunnel_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Set default_port for existing built-in tool types
|
||||
op.execute("""
|
||||
UPDATE tool_types
|
||||
SET default_port = CASE
|
||||
WHEN name = 'code-server' THEN 8443
|
||||
WHEN name = 'jupyter-notebook' THEN 8888
|
||||
WHEN name = 'opencode' THEN 3000
|
||||
ELSE 8080
|
||||
END
|
||||
WHERE default_port IS NULL
|
||||
""")
|
||||
|
||||
# Make default_port non-nullable
|
||||
op.alter_column(
|
||||
"tool_types",
|
||||
"default_port",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"tool_types",
|
||||
"default_port",
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""add_tool_config_fields
|
||||
|
||||
Revision ID: 398082499c30
|
||||
Revises: af8512103d67
|
||||
Create Date: 2026-05-22 18:38:20.166184
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '398082499c30'
|
||||
down_revision = 'af8512103d67'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to tool_configs
|
||||
op.add_column('tool_configs', sa.Column('port_override', sa.Integer(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('start_command', sa.Text(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('working_directory', sa.Text(), nullable=True))
|
||||
op.add_column('tool_configs', sa.Column('environment_variables', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||
op.add_column('tool_configs', sa.Column('volumes', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='[]'))
|
||||
|
||||
# Add CHECK constraint for port range
|
||||
op.create_check_constraint('chk_port_range', 'tool_configs', sa.text('port_override IS NULL OR (port_override >= 1 AND port_override <= 65535)'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop CHECK constraint
|
||||
op.drop_constraint('chk_port_range', 'tool_configs', type_='check')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('tool_configs', 'port_override')
|
||||
op.drop_column('tool_configs', 'start_command')
|
||||
op.drop_column('tool_configs', 'working_directory')
|
||||
op.drop_column('tool_configs', 'environment_variables')
|
||||
op.drop_column('tool_configs', 'volumes')
|
||||
@@ -1,51 +0,0 @@
|
||||
"""add repository_connection
|
||||
|
||||
Revision ID: 42a78fd41e23
|
||||
Revises: 6cfa61694d0a
|
||||
Create Date: 2026-05-14 08:19:37.912177
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '42a78fd41e23'
|
||||
down_revision: Union[str, Sequence[str], None] = '6cfa61694d0a'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('repository_connection',
|
||||
sa.Column('project_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('repository_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('provider_kind', sa.String(length=50), nullable=False),
|
||||
sa.Column('credential_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('connection_status', sa.String(length=50), nullable=False),
|
||||
sa.Column('default_branch', sa.String(length=100), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
|
||||
sa.ForeignKeyConstraint(['repository_id'], ['repository.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_repository_connection_credential_id'), 'repository_connection', ['credential_id'], unique=False)
|
||||
op.create_index(op.f('ix_repository_connection_project_id'), 'repository_connection', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_repository_connection_repository_id'), 'repository_connection', ['repository_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_repository_connection_repository_id'), table_name='repository_connection')
|
||||
op.drop_index(op.f('ix_repository_connection_project_id'), table_name='repository_connection')
|
||||
op.drop_index(op.f('ix_repository_connection_credential_id'), table_name='repository_connection')
|
||||
op.drop_table('repository_connection')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,172 +0,0 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 6cfa61694d0a
|
||||
Revises:
|
||||
Create Date: 2026-05-14 06:16:27.700389
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6cfa61694d0a'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('secret',
|
||||
sa.Column('scope_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('scope_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('encrypted_value', sa.Text(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('scope_type', 'scope_id', 'key')
|
||||
)
|
||||
op.create_index(op.f('ix_secret_scope_id'), 'secret', ['scope_id'], unique=False)
|
||||
op.create_table('tool_definition',
|
||||
sa.Column('key', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('version', sa.String(length=50), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('image', sa.Text(), nullable=False),
|
||||
sa.Column('manifest_data', sa.JSON(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tool_definition_key'), 'tool_definition', ['key'], unique=True)
|
||||
op.create_table('user',
|
||||
sa.Column('authentik_sub', sa.String(length=255), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_user_authentik_sub'), 'user', ['authentik_sub'], unique=True)
|
||||
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
|
||||
op.create_table('config',
|
||||
sa.Column('scope_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('scope_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tool_definition_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('value', sa.JSON(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('scope_type', 'scope_id', 'tool_definition_id', 'key')
|
||||
)
|
||||
op.create_index(op.f('ix_config_scope_id'), 'config', ['scope_id'], unique=False)
|
||||
op.create_index(op.f('ix_config_tool_definition_id'), 'config', ['tool_definition_id'], unique=False)
|
||||
op.create_table('project',
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('slug', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('owner_id', 'slug')
|
||||
)
|
||||
op.create_index(op.f('ix_project_owner_id'), 'project', ['owner_id'], unique=False)
|
||||
op.create_table('repository',
|
||||
sa.Column('project_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('git_url', sa.Text(), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('default_branch', sa.String(length=100), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_repository_project_id'), 'repository', ['project_id'], unique=False)
|
||||
op.create_table('tool_instance',
|
||||
sa.Column('project_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tool_definition_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('container_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('subdomain', sa.String(length=255), nullable=True),
|
||||
sa.Column('config_override', sa.JSON(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
|
||||
sa.ForeignKeyConstraint(['tool_definition_id'], ['tool_definition.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('subdomain')
|
||||
)
|
||||
op.create_index(op.f('ix_tool_instance_project_id'), 'tool_instance', ['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_tool_instance_tool_definition_id'), 'tool_instance', ['tool_definition_id'], unique=False)
|
||||
op.create_table('workspace',
|
||||
sa.Column('project_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('mount_path', sa.Text(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_workspace_project_id'), 'workspace', ['project_id'], unique=False)
|
||||
op.create_table('access_route',
|
||||
sa.Column('tool_instance_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('domain', sa.Text(), nullable=False),
|
||||
sa.Column('path_prefix', sa.String(length=255), nullable=False),
|
||||
sa.Column('provider_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('provider_config', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tool_instance_id'], ['tool_instance.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_access_route_tool_instance_id'), 'access_route', ['tool_instance_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_access_route_tool_instance_id'), table_name='access_route')
|
||||
op.drop_table('access_route')
|
||||
op.drop_index(op.f('ix_workspace_project_id'), table_name='workspace')
|
||||
op.drop_table('workspace')
|
||||
op.drop_index(op.f('ix_tool_instance_tool_definition_id'), table_name='tool_instance')
|
||||
op.drop_index(op.f('ix_tool_instance_project_id'), table_name='tool_instance')
|
||||
op.drop_table('tool_instance')
|
||||
op.drop_index(op.f('ix_repository_project_id'), table_name='repository')
|
||||
op.drop_table('repository')
|
||||
op.drop_index(op.f('ix_project_owner_id'), table_name='project')
|
||||
op.drop_table('project')
|
||||
op.drop_index(op.f('ix_config_tool_definition_id'), table_name='config')
|
||||
op.drop_index(op.f('ix_config_scope_id'), table_name='config')
|
||||
op.drop_table('config')
|
||||
op.drop_index(op.f('ix_user_email'), table_name='user')
|
||||
op.drop_index(op.f('ix_user_authentik_sub'), table_name='user')
|
||||
op.drop_table('user')
|
||||
op.drop_index(op.f('ix_tool_definition_key'), table_name='tool_definition')
|
||||
op.drop_table('tool_definition')
|
||||
op.drop_index(op.f('ix_secret_scope_id'), table_name='secret')
|
||||
op.drop_table('secret')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,44 @@
|
||||
"""create_config_folders_table
|
||||
|
||||
Revision ID: 8ed7dd80973d
|
||||
Revises: 398082499c30
|
||||
Create Date: 2026-05-22 18:38:22.133696
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8ed7dd80973d'
|
||||
down_revision = '398082499c30'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'config_folders',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('mount_path', sa.String(1024), nullable=False),
|
||||
sa.Column('files', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='{}'),
|
||||
sa.Column('project_overrides', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.UniqueConstraint('user_id', 'name', name='uq_config_folders_user_name')
|
||||
)
|
||||
|
||||
# Add index on user_id for filtering
|
||||
op.create_index('idx_config_folders_user', 'config_folders', ['user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index
|
||||
op.drop_index('idx_config_folders_user', table_name='config_folders')
|
||||
|
||||
# Drop table
|
||||
op.drop_table('config_folders')
|
||||
@@ -0,0 +1,38 @@
|
||||
"""add_tool_type_fields
|
||||
|
||||
Revision ID: af8512103d67
|
||||
Revises: 0012_default_port_req
|
||||
Create Date: 2026-05-22 18:37:56.607240
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'af8512103d67'
|
||||
down_revision = '0012_default_port_req'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to tool_types
|
||||
op.add_column('tool_types', sa.Column('definition_type', sa.String(20), nullable=False, server_default='compose'))
|
||||
op.add_column('tool_types', sa.Column('dockerfile_template', sa.Text(), nullable=True))
|
||||
op.add_column('tool_types', sa.Column('build_context', postgresql.JSONB(astext_type=sa.Text()), nullable=True, server_default='{}'))
|
||||
op.add_column('tool_types', sa.Column('readiness_probe', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
|
||||
# Add CHECK constraint for definition_type
|
||||
op.create_check_constraint('chk_definition_type', 'tool_types', sa.text("definition_type IN ('compose', 'dockerfile')"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop CHECK constraint
|
||||
op.drop_constraint('chk_definition_type', 'tool_types', type_='check')
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('tool_types', 'definition_type')
|
||||
op.drop_column('tool_types', 'dockerfile_template')
|
||||
op.drop_column('tool_types', 'build_context')
|
||||
op.drop_column('tool_types', 'readiness_probe')
|
||||
@@ -1,3 +0,0 @@
|
||||
from app.auth.dependencies import get_current_active_user, get_current_user
|
||||
|
||||
__all__ = ["get_current_user", "get_current_active_user"]
|
||||
@@ -1,136 +0,0 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import decode_token
|
||||
from app.config import settings
|
||||
from app.db import get_db_session
|
||||
from app.models.user import User
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def _get_or_create_dev_user(session: AsyncSession) -> User:
|
||||
"""Return or create the fixed development user."""
|
||||
result = await session.execute(
|
||||
select(User).where(User.authentik_sub == "dev-user")
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
authentik_sub="dev-user",
|
||||
email="dev@localhost",
|
||||
display_name="Dev User",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
if token is None:
|
||||
if settings.debug and settings.auth_dev_bypass:
|
||||
return await _get_or_create_dev_user(session)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
claims = decode_token(token.credentials)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {exc}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
authentik_sub = claims.get("sub")
|
||||
email = claims.get("email", "")
|
||||
display_name = claims.get("name") or claims.get("preferred_username") or email
|
||||
|
||||
if not authentik_sub:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token missing 'sub' claim",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.authentik_sub == authentik_sub)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
authentik_sub=authentik_sub,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def validate_traefik_auth(
|
||||
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
if token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
claims = decode_token(token.credentials)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid token: {exc}",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
authentik_sub = claims.get("sub")
|
||||
if not authentik_sub:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token missing 'sub' claim",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(User).where(User.authentik_sub == authentik_sub)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""Decode a JWT token.
|
||||
|
||||
When authentik_issuer_url is configured, validates the token
|
||||
against the OIDC discovery document JWKS.
|
||||
Otherwise, decodes without verification (local development only).
|
||||
"""
|
||||
if settings.authentik_issuer_url:
|
||||
import httpx
|
||||
|
||||
issuer = settings.authentik_issuer_url.rstrip("/")
|
||||
discovery_url = f"{issuer}/.well-known/openid-configuration"
|
||||
with httpx.Client() as client:
|
||||
resp = client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
discovery = resp.json()
|
||||
jwks_uri = discovery["jwks_uri"]
|
||||
|
||||
jwks_resp = client.get(jwks_uri)
|
||||
jwks_resp.raise_for_status()
|
||||
jwks = jwks_resp.json()
|
||||
|
||||
signing_key = jwt.algorithms.RSAAlgorithm.from_jwk(
|
||||
_find_matching_key(jwks, token)
|
||||
)
|
||||
|
||||
return jwt.decode(
|
||||
token,
|
||||
signing_key, # type: ignore[arg-type]
|
||||
algorithms=["RS256"],
|
||||
audience=settings.authentik_client_id,
|
||||
issuer=settings.authentik_issuer_url,
|
||||
)
|
||||
|
||||
return jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
|
||||
def _find_matching_key(jwks: dict[str, Any], token: str) -> dict[str, Any]:
|
||||
"""Find the key in JWKS that matches the token's kid header."""
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
for key in jwks.get("keys", []):
|
||||
key_dict: dict[str, Any] = key
|
||||
if key_dict.get("kid") == kid:
|
||||
return key_dict
|
||||
raise RuntimeError(f"No matching JWKS key found for kid={kid}")
|
||||
@@ -1,36 +0,0 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "Headquarter API"
|
||||
debug: bool = False
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
# Authentik OIDC
|
||||
authentik_issuer_url: str = ""
|
||||
authentik_client_id: str = ""
|
||||
authentik_client_secret: str = ""
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql://postgres:postgres@localhost:5432/headquarter"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173"
|
||||
|
||||
# Deployment
|
||||
root_domain: str = "localhost"
|
||||
tool_subdomain_pattern: str = "{tool}-{project}-{user}.tools.{root_domain}"
|
||||
|
||||
# Auth & encryption
|
||||
secret_encryption_key: str = "change-me-in-production"
|
||||
access_token_expire_minutes: int = 60
|
||||
auth_dev_bypass: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,25 +0,0 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Rewrite sync postgres URL to asyncpg
|
||||
DATABASE_URL = settings.database_url
|
||||
if DATABASE_URL.startswith("postgresql://"):
|
||||
DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=settings.debug)
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
@@ -1,30 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _derive_fernet_key(key: str) -> bytes:
|
||||
"""Derive a URL-safe base64-encoded 32-byte Fernet key from any string."""
|
||||
digest = hashlib.sha256(key.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
_fernet = Fernet(_derive_fernet_key(settings.secret_encryption_key))
|
||||
|
||||
|
||||
def encrypt_value(plain_text: str) -> str:
|
||||
"""Encrypt a plaintext string and return the ciphertext as a string."""
|
||||
token = _fernet.encrypt(plain_text.encode("utf-8"))
|
||||
return token.decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_value(cipher_text: str) -> str:
|
||||
"""Decrypt a ciphertext string and return the plaintext."""
|
||||
try:
|
||||
plain = _fernet.decrypt(cipher_text.encode("utf-8"))
|
||||
except InvalidToken as exc:
|
||||
raise RuntimeError("Invalid encryption token — secret cannot be decrypted") from exc
|
||||
return plain.decode("utf-8")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Git provider abstraction, credentials, SSH keys, and operations."""
|
||||
|
||||
from app.git.connection import ConnectionManager, RepositoryConnectionData
|
||||
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
|
||||
from app.git.operations import GitOperations, LocalGitOperations
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.ssh_key import SshKeyLifecycle, SshKeyPair
|
||||
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
|
||||
|
||||
__all__ = [
|
||||
"AccessTokenCredential",
|
||||
"ConnectionManager",
|
||||
"ConnectionStatus",
|
||||
"CredentialKind",
|
||||
"CredentialStorage",
|
||||
"GitCredential",
|
||||
"GitOperations",
|
||||
"GitProvider",
|
||||
"LocalGitOperations",
|
||||
"ProviderKind",
|
||||
"RepositoryConnectionData",
|
||||
"SshKeyLifecycle",
|
||||
"SshKeyPair",
|
||||
"SshKeyStatus",
|
||||
]
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Repository connection orchestration."""
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.git.credentials import CredentialStorage, GitCredential
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.types import ConnectionStatus, ProviderKind
|
||||
from app.models.repository_connection import RepositoryConnection
|
||||
|
||||
|
||||
class RepositoryConnectionData(BaseModel):
|
||||
"""Domain-level read model for a repository connection."""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
repository_id: uuid.UUID | None
|
||||
provider_kind: ProviderKind
|
||||
credential_id: uuid.UUID | None
|
||||
connection_status: ConnectionStatus
|
||||
default_branch: str | None
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Orchestrates creating, validating, and retrieving repository connections."""
|
||||
|
||||
def __init__(self, provider: GitProvider, storage: CredentialStorage) -> None:
|
||||
self.provider = provider
|
||||
self.storage = storage
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
git_url: str,
|
||||
credential: GitCredential,
|
||||
) -> RepositoryConnectionData:
|
||||
"""Store *credential*, create a connection row, and validate with the provider."""
|
||||
credential_id = self.storage.create(credential)
|
||||
|
||||
row = RepositoryConnection(
|
||||
project_id=project_id,
|
||||
provider_kind=str(self.provider.get_kind()),
|
||||
credential_id=credential_id,
|
||||
connection_status=str(ConnectionStatus.pending),
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
|
||||
try:
|
||||
status = self.provider.validate_connection(
|
||||
git_url, str(credential_id)
|
||||
)
|
||||
except Exception:
|
||||
row.connection_status = str(ConnectionStatus.error)
|
||||
await session.flush()
|
||||
raise RuntimeError("Connection validation failed")
|
||||
|
||||
if status == ConnectionStatus.connected:
|
||||
row.connection_status = str(ConnectionStatus.connected)
|
||||
else:
|
||||
row.connection_status = str(ConnectionStatus.error)
|
||||
await session.flush()
|
||||
raise RuntimeError("Connection validation failed")
|
||||
|
||||
await session.flush()
|
||||
return _map_row(row)
|
||||
|
||||
async def disconnect(
|
||||
self, session: AsyncSession, connection_id: uuid.UUID
|
||||
) -> None:
|
||||
"""Mark the connection as disconnected."""
|
||||
row = await session.get(RepositoryConnection, connection_id)
|
||||
if row is None:
|
||||
return
|
||||
row.connection_status = str(ConnectionStatus.disconnected)
|
||||
await session.flush()
|
||||
|
||||
async def get_connection(
|
||||
self, session: AsyncSession, connection_id: uuid.UUID
|
||||
) -> RepositoryConnectionData | None:
|
||||
"""Fetch a connection by ID and map it to the Pydantic read model."""
|
||||
row = await session.get(RepositoryConnection, connection_id)
|
||||
if row is None:
|
||||
return None
|
||||
return _map_row(row)
|
||||
|
||||
|
||||
def _map_row(row: RepositoryConnection) -> RepositoryConnectionData:
|
||||
return RepositoryConnectionData(
|
||||
id=row.id,
|
||||
project_id=row.project_id,
|
||||
repository_id=row.repository_id,
|
||||
provider_kind=ProviderKind(row.provider_kind),
|
||||
credential_id=row.credential_id,
|
||||
connection_status=ConnectionStatus(row.connection_status),
|
||||
default_branch=row.default_branch,
|
||||
)
|
||||
@@ -1,39 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.git.credentials import CredentialStorage, GitCredential
|
||||
from app.models.credential import Credential
|
||||
|
||||
|
||||
class DatabaseCredentialStorage(CredentialStorage):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
row = Credential(
|
||||
id=credential.id,
|
||||
kind=str(credential.kind),
|
||||
encrypted_payload=credential.encrypted_payload,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return row.id
|
||||
|
||||
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
row = await self.session.get(Credential, credential_id)
|
||||
if row is None:
|
||||
return None
|
||||
return GitCredential(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
encrypted_payload=row.encrypted_payload,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||
row = await self.session.get(Credential, credential_id)
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Credential models and storage interface.
|
||||
|
||||
Security rules:
|
||||
- No plaintext ``private_key`` or ``token`` fields exist on any model class.
|
||||
- The ``encrypted_payload`` field is opaque bytes encoded as a string.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.git.types import CredentialKind
|
||||
|
||||
|
||||
class GitCredential(BaseModel):
|
||||
"""Base credential model.
|
||||
|
||||
Never stores plaintext secrets. The ``encrypted_payload`` field holds
|
||||
opaque encrypted data.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||
kind: CredentialKind
|
||||
encrypted_payload: str = Field(repr=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AccessTokenCredential(GitCredential):
|
||||
"""Access-token credential discriminated by ``kind``."""
|
||||
|
||||
kind: CredentialKind = CredentialKind.access_token
|
||||
|
||||
|
||||
class CredentialStorage(abc.ABC):
|
||||
"""Abstract storage backend for :class:`GitCredential` records."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
"""Persist *credential* and return its ID."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
"""Retrieve a credential by ID, or ``None`` if not found."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete(self, credential_id: uuid.UUID) -> None:
|
||||
"""Remove a credential by ID."""
|
||||
@@ -1,100 +0,0 @@
|
||||
import abc
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GitOperations(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def push(self, repo_path: Path, credential_id: str) -> None:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
|
||||
class LocalGitOperations(GitOperations):
|
||||
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
|
||||
cmd = ["git", "clone", git_url, str(dest)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Git clone failed: {result.stderr}")
|
||||
|
||||
def fetch(self, repo_path: Path, credential_id: str) -> None:
|
||||
cmd = ["git", "-C", str(repo_path), "fetch", "--all"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Git fetch failed: {result.stderr}")
|
||||
|
||||
def push(self, repo_path: Path, credential_id: str) -> None:
|
||||
cmd = ["git", "-C", str(repo_path), "push"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Git push failed: {result.stderr}")
|
||||
|
||||
def get_status(self, repo_path: Path) -> dict[str, Any]:
|
||||
if not repo_path.exists() or not (repo_path / ".git").is_dir():
|
||||
raise RuntimeError("Not a git repository")
|
||||
|
||||
try:
|
||||
branch_result = subprocess.run(
|
||||
["git", "-C", str(repo_path), "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
branch = branch_result.stdout.strip()
|
||||
|
||||
status_result = subprocess.run(
|
||||
["git", "-C", str(repo_path), "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError("Git command failed") from exc
|
||||
|
||||
untracked: list[str] = []
|
||||
modified: list[str] = []
|
||||
staged: list[str] = []
|
||||
deleted: list[str] = []
|
||||
|
||||
for line in status_result.stdout.splitlines():
|
||||
if len(line) < 3:
|
||||
continue
|
||||
index_status = line[0]
|
||||
worktree_status = line[1]
|
||||
filename = line[3:]
|
||||
|
||||
if index_status == "?" and worktree_status == "?":
|
||||
untracked.append(filename)
|
||||
elif index_status in ("M", "A"):
|
||||
staged.append(filename)
|
||||
|
||||
if index_status == "D" or worktree_status == "D":
|
||||
deleted.append(filename)
|
||||
|
||||
if worktree_status == "M":
|
||||
modified.append(filename)
|
||||
|
||||
clean = not (untracked or modified or staged or deleted)
|
||||
|
||||
return {
|
||||
"branch": branch,
|
||||
"clean": clean,
|
||||
"untracked": untracked,
|
||||
"modified": modified,
|
||||
"staged": staged,
|
||||
"deleted": deleted,
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Abstract base class for Git provider adapters."""
|
||||
|
||||
import abc
|
||||
from typing import Any
|
||||
|
||||
from app.git.types import ConnectionStatus, ProviderKind
|
||||
|
||||
|
||||
class GitProvider(abc.ABC):
|
||||
"""Provider API adapter for remote Git operations.
|
||||
|
||||
This abstraction is separate from :class:`~app.git.operations.GitOperations`,
|
||||
which handles local Git subprocess workflows.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_kind(self) -> ProviderKind:
|
||||
"""Return the provider kind identifier."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def validate_connection(
|
||||
self, git_url: str, credential_id: str
|
||||
) -> ConnectionStatus:
|
||||
"""Validate that the given credential can access *git_url*.
|
||||
|
||||
Returns a :class:`ConnectionStatus` indicating the result.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||
"""List repositories accessible with *credential_id*."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def create_deploy_key(
|
||||
self, git_url: str, public_key: str
|
||||
) -> str:
|
||||
"""Register a deploy key on the remote provider.
|
||||
|
||||
Returns the provider-side deploy key ID.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||
"""Remove a previously registered deploy key."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||
"""Return the default branch name for the repository at *git_url*."""
|
||||
@@ -1,17 +0,0 @@
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.types import ProviderKind
|
||||
|
||||
from .github import GitHubAdapter
|
||||
from .gitlab import GitLabAdapter
|
||||
|
||||
PROVIDERS: dict[ProviderKind, type[GitProvider]] = {
|
||||
ProviderKind.github: GitHubAdapter,
|
||||
ProviderKind.gitlab: GitLabAdapter,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(kind: ProviderKind) -> GitProvider:
|
||||
provider_class = PROVIDERS.get(kind)
|
||||
if provider_class is None:
|
||||
raise ValueError(f"Unsupported provider kind: {kind}")
|
||||
return provider_class()
|
||||
@@ -1,40 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.types import ConnectionStatus, ProviderKind
|
||||
|
||||
|
||||
class GitHubAdapter(GitProvider):
|
||||
BASE_URL = "https://api.github.com"
|
||||
|
||||
def get_kind(self) -> ProviderKind:
|
||||
return ProviderKind.github
|
||||
|
||||
def _get_headers(self, token: str) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
def _extract_owner_repo(self, git_url: str) -> tuple[str, str]:
|
||||
clean = git_url.replace("https://github.com/", "")
|
||||
clean = clean.replace("git@github.com:", "")
|
||||
clean = clean.replace(".git", "")
|
||||
parts = clean.split("/")
|
||||
return parts[0], parts[1]
|
||||
|
||||
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
|
||||
return ConnectionStatus.connected
|
||||
|
||||
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def create_deploy_key(self, git_url: str, public_key: str) -> str:
|
||||
return ""
|
||||
|
||||
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||
return
|
||||
|
||||
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||
return "main"
|
||||
@@ -1,35 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.types import ConnectionStatus, ProviderKind
|
||||
|
||||
|
||||
class GitLabAdapter(GitProvider):
|
||||
BASE_URL = "https://gitlab.com/api/v4"
|
||||
|
||||
def get_kind(self) -> ProviderKind:
|
||||
return ProviderKind.gitlab
|
||||
|
||||
def _get_headers(self, token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def _extract_project_path(self, git_url: str) -> str:
|
||||
path = git_url.replace("https://gitlab.com/", "")
|
||||
path = path.replace("git@gitlab.com:", "")
|
||||
path = path.replace(".git", "")
|
||||
return path
|
||||
|
||||
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
|
||||
return ConnectionStatus.connected
|
||||
|
||||
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def create_deploy_key(self, git_url: str, public_key: str) -> str:
|
||||
return ""
|
||||
|
||||
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||
return
|
||||
|
||||
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||
return "main"
|
||||
@@ -1,82 +0,0 @@
|
||||
"""SSH key pair generation and lifecycle management.
|
||||
|
||||
Security rules:
|
||||
- Private key material must never appear in logs, exceptions, ``__repr__``,
|
||||
or test output.
|
||||
- The ``encrypted_private_key`` field uses ``repr=False``.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.git.types import SshKeyStatus
|
||||
|
||||
|
||||
def encrypt_private_key(raw: bytes) -> str:
|
||||
from app.encryption import encrypt_value
|
||||
|
||||
return encrypt_value(raw.decode("utf-8"))
|
||||
|
||||
|
||||
class SshKeyPair(BaseModel):
|
||||
"""An Ed25519 SSH key pair belonging to a repository connection."""
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||
connection_id: uuid.UUID
|
||||
public_key: str
|
||||
encrypted_private_key: str = Field(repr=False)
|
||||
status: SshKeyStatus = SshKeyStatus.generated
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
class SshKeyLifecycle:
|
||||
"""Generate and transition SSH key pairs."""
|
||||
|
||||
@staticmethod
|
||||
def generate(connection_id: uuid.UUID) -> SshKeyPair:
|
||||
"""Generate a new Ed25519 key pair for *connection_id*."""
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
public_key = private_key.public_key()
|
||||
|
||||
public_key_pem = public_key.public_bytes(
|
||||
Encoding.OpenSSH, PublicFormat.OpenSSH
|
||||
).decode("utf-8")
|
||||
|
||||
private_key_pem = private_key.private_bytes(
|
||||
Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
|
||||
)
|
||||
|
||||
encrypted = encrypt_private_key(private_key_pem)
|
||||
|
||||
return SshKeyPair(
|
||||
connection_id=connection_id,
|
||||
public_key=public_key_pem,
|
||||
encrypted_private_key=encrypted,
|
||||
status=SshKeyStatus.generated,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transition(key: SshKeyPair, new_status: SshKeyStatus) -> SshKeyPair:
|
||||
"""Update *key* status and timestamps.
|
||||
|
||||
Sets ``revoked_at`` when transitioning to :attr:`SshKeyStatus.revoked`.
|
||||
"""
|
||||
key.status = new_status
|
||||
key.updated_at = datetime.now(UTC)
|
||||
if new_status == SshKeyStatus.revoked:
|
||||
key.revoked_at = datetime.now(UTC)
|
||||
return key
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Enumerations for Git provider abstraction."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ProviderKind(StrEnum):
|
||||
"""Supported Git provider kinds."""
|
||||
|
||||
github = "github"
|
||||
gitlab = "gitlab"
|
||||
gitea = "gitea"
|
||||
forgejo = "forgejo"
|
||||
generic = "generic"
|
||||
|
||||
|
||||
class CredentialKind(StrEnum):
|
||||
"""Supported credential kinds for Git authentication."""
|
||||
|
||||
ssh_key = "ssh_key"
|
||||
access_token = "access_token"
|
||||
|
||||
|
||||
class ConnectionStatus(StrEnum):
|
||||
"""Lifecycle states for a repository connection."""
|
||||
|
||||
pending = "pending"
|
||||
connected = "connected"
|
||||
disconnected = "disconnected"
|
||||
error = "error"
|
||||
|
||||
|
||||
class SshKeyStatus(StrEnum):
|
||||
"""Lifecycle states for an SSH key pair."""
|
||||
|
||||
generated = "generated"
|
||||
registered = "registered"
|
||||
rotating = "rotating"
|
||||
revoked = "revoked"
|
||||
@@ -1,65 +0,0 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import AsyncSessionLocal, engine
|
||||
from app.routers import routers
|
||||
from app.tools.registry import registry
|
||||
from app.tools.router import router as tools_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
registry.load_builtin_manifests()
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Database connectivity check failed on startup")
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.debug,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
allow_origins = ["*"] if settings.debug else []
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allow_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
for router in routers:
|
||||
app.include_router(router, prefix=settings.api_v1_prefix)
|
||||
|
||||
app.include_router(tools_router, prefix=settings.api_v1_prefix)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> JSONResponse:
|
||||
db_status = "connected"
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
except Exception:
|
||||
db_status = "unreachable"
|
||||
|
||||
content = {
|
||||
"status": "ok" if db_status == "connected" else "degraded",
|
||||
"service": settings.app_name,
|
||||
"database": db_status,
|
||||
}
|
||||
status_code = 200 if db_status == "connected" else 503
|
||||
return JSONResponse(status_code=status_code, content=content)
|
||||
@@ -1,27 +0,0 @@
|
||||
from app.models.access_route import AccessRoute
|
||||
from app.models.base import Base
|
||||
from app.models.config import Config
|
||||
from app.models.credential import Credential
|
||||
from app.models.project import Project
|
||||
from app.models.repository import Repository
|
||||
from app.models.repository_connection import RepositoryConnection
|
||||
from app.models.secret import Secret
|
||||
from app.models.tool_definition import ToolDefinition
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"AccessRoute",
|
||||
"Config",
|
||||
"Credential",
|
||||
"Project",
|
||||
"Repository",
|
||||
"RepositoryConnection",
|
||||
"Secret",
|
||||
"ToolDefinition",
|
||||
"ToolInstance",
|
||||
"User",
|
||||
"Workspace",
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tool_instance import ToolInstance
|
||||
|
||||
|
||||
class AccessRoute(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "access_route"
|
||||
|
||||
tool_instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tool_instance.id"), index=True
|
||||
)
|
||||
domain: Mapped[str] = mapped_column(Text)
|
||||
path_prefix: Mapped[str] = mapped_column(
|
||||
String(255), default="/"
|
||||
)
|
||||
provider_type: Mapped[str] = mapped_column(
|
||||
String(50), default="traefik"
|
||||
)
|
||||
provider_config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
)
|
||||
|
||||
tool_instance: Mapped["ToolInstance"] = relationship(
|
||||
back_populates="access_routes"
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
@@ -1,22 +0,0 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Config(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "config"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope_type", "scope_id", "tool_definition_id", "key"),
|
||||
)
|
||||
|
||||
scope_type: Mapped[str] = mapped_column(String(50))
|
||||
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
|
||||
tool_definition_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tool_definition.id"), nullable=True, index=True
|
||||
)
|
||||
key: Mapped[str] = mapped_column(String(255))
|
||||
value: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
@@ -1,16 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Credential(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "credential"
|
||||
|
||||
kind: Mapped[str] = mapped_column(String(50))
|
||||
encrypted_payload: Mapped[str] = mapped_column(Text)
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.repository import Repository
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
|
||||
class Project(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "project"
|
||||
__table_args__ = (UniqueConstraint("owner_id", "slug"),)
|
||||
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user.id"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
slug: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
owner: Mapped["User"] = relationship(back_populates="projects")
|
||||
repositories: Mapped[list["Repository"]] = relationship(
|
||||
back_populates="project"
|
||||
)
|
||||
workspaces: Mapped[list["Workspace"]] = relationship(
|
||||
back_populates="project"
|
||||
)
|
||||
tool_instances: Mapped[list["ToolInstance"]] = relationship(
|
||||
back_populates="project"
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.project import Project
|
||||
from app.models.repository_connection import RepositoryConnection
|
||||
|
||||
|
||||
class Repository(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "repository"
|
||||
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("project.id"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
git_url: Mapped[str] = mapped_column(Text)
|
||||
provider_type: Mapped[str] = mapped_column(
|
||||
String(50), default="generic"
|
||||
)
|
||||
default_branch: Mapped[str] = mapped_column(
|
||||
String(100), default="main"
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship(
|
||||
back_populates="repositories"
|
||||
)
|
||||
connections: Mapped[list["RepositoryConnection"]] = relationship(
|
||||
back_populates="repository"
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
"""RepositoryConnection links a project to a Git repository via a provider."""
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.repository import Repository
|
||||
|
||||
|
||||
class RepositoryConnection(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "repository_connection"
|
||||
# NOTE: A partial unique index on (project_id, repository_id, provider_kind)
|
||||
# when repository_id IS NOT NULL is deferred for MVP. Duplicate connections
|
||||
# are acceptable until explicit disambiguation is required.
|
||||
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("project.id"), index=True
|
||||
)
|
||||
repository_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("repository.id"), nullable=True, index=True
|
||||
)
|
||||
provider_kind: Mapped[str] = mapped_column(
|
||||
String(50), default="generic"
|
||||
)
|
||||
credential_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
index=True, nullable=True
|
||||
)
|
||||
connection_status: Mapped[str] = mapped_column(
|
||||
String(50), default="pending"
|
||||
)
|
||||
default_branch: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True
|
||||
)
|
||||
|
||||
repository: Mapped["Repository"] = relationship(
|
||||
back_populates="connections"
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Secret(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "secret"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope_type", "scope_id", "key"),
|
||||
)
|
||||
|
||||
scope_type: Mapped[str] = mapped_column(String(50))
|
||||
scope_id: Mapped[uuid.UUID] = mapped_column(index=True)
|
||||
key: Mapped[str] = mapped_column(String(255))
|
||||
encrypted_value: Mapped[str] = mapped_column(Text)
|
||||
@@ -1,32 +0,0 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tool_instance import ToolInstance
|
||||
|
||||
|
||||
class ToolDefinition(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "tool_definition"
|
||||
|
||||
key: Mapped[str] = mapped_column(
|
||||
String(100), unique=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(50), default="1.0.0"
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
image: Mapped[str] = mapped_column(Text)
|
||||
manifest_data: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
instances: Mapped[list["ToolInstance"]] = relationship(
|
||||
back_populates="tool_definition"
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.access_route import AccessRoute
|
||||
from app.models.project import Project
|
||||
from app.models.tool_definition import ToolDefinition
|
||||
|
||||
|
||||
class ToolInstance(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "tool_instance"
|
||||
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("project.id"), index=True
|
||||
)
|
||||
tool_definition_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tool_definition.id"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), default="pending"
|
||||
)
|
||||
container_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
subdomain: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, unique=True
|
||||
)
|
||||
config_override: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
traefik_labels: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON, nullable=True
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship(
|
||||
back_populates="tool_instances"
|
||||
)
|
||||
tool_definition: Mapped["ToolDefinition"] = relationship(
|
||||
back_populates="instances"
|
||||
)
|
||||
access_routes: Mapped[list["AccessRoute"]] = relationship(
|
||||
back_populates="tool_instance"
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.project import Project
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "user"
|
||||
|
||||
authentik_sub: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, index=True
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship(
|
||||
back_populates="owner"
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.project import Project
|
||||
|
||||
|
||||
class Workspace(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "workspace"
|
||||
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("project.id"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
mount_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
project: Mapped["Project"] = relationship(
|
||||
back_populates="workspaces"
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.routers.access_routes import router as access_routes_router
|
||||
from app.routers.configs import router as configs_router
|
||||
from app.routers.projects import router as projects_router
|
||||
from app.routers.repositories import router as repositories_router
|
||||
from app.routers.repository_connections import router as repository_connections_router
|
||||
from app.routers.secrets import router as secrets_router
|
||||
from app.routers.tool_definitions import router as tool_definitions_router
|
||||
from app.routers.tool_instances import router as tool_instances_router
|
||||
from app.routers.users import router as users_router
|
||||
from app.routers.workspaces import router as workspaces_router
|
||||
|
||||
routers: list[APIRouter] = [
|
||||
access_routes_router,
|
||||
configs_router,
|
||||
projects_router,
|
||||
repositories_router,
|
||||
repository_connections_router,
|
||||
secrets_router,
|
||||
tool_definitions_router,
|
||||
tool_instances_router,
|
||||
users_router,
|
||||
workspaces_router,
|
||||
]
|
||||
|
||||
__all__ = ["routers"]
|
||||
@@ -1,103 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.access_route import AccessRoute
|
||||
from app.models.project import Project
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
|
||||
|
||||
router = APIRouter(tags=["access-routes"])
|
||||
|
||||
|
||||
async def _verify_tool_instance_ownership(
|
||||
instance_id: UUID, user: User, session: AsyncSession
|
||||
) -> None:
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
|
||||
project = await session.get(Project, ti.project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
|
||||
@router.post("/tool-instances/{instance_id}/access-routes", response_model=AccessRouteRead, status_code=status.HTTP_201_CREATED) # noqa: E501
|
||||
async def create_access_route(
|
||||
instance_id: UUID,
|
||||
ar_in: AccessRouteCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> AccessRoute:
|
||||
await _verify_tool_instance_ownership(instance_id, current_user, session)
|
||||
ar = AccessRoute(**ar_in.model_dump(), tool_instance_id=instance_id)
|
||||
session.add(ar)
|
||||
await session.commit()
|
||||
await session.refresh(ar)
|
||||
return ar
|
||||
|
||||
|
||||
@router.get("/tool-instances/{instance_id}/access-routes", response_model=list[AccessRouteRead])
|
||||
async def list_access_routes(
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[AccessRoute]:
|
||||
await _verify_tool_instance_ownership(instance_id, current_user, session)
|
||||
result = await session.execute(
|
||||
select(AccessRoute).where(AccessRoute.tool_instance_id == instance_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
|
||||
async def get_access_route(
|
||||
instance_id: UUID,
|
||||
route_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> AccessRoute:
|
||||
await _verify_tool_instance_ownership(instance_id, current_user, session)
|
||||
ar = await session.get(AccessRoute, route_id)
|
||||
if not ar or ar.tool_instance_id != instance_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
|
||||
return ar
|
||||
|
||||
|
||||
@router.put("/tool-instances/{instance_id}/access-routes/{route_id}", response_model=AccessRouteRead) # noqa: E501
|
||||
async def update_access_route(
|
||||
instance_id: UUID,
|
||||
route_id: UUID,
|
||||
ar_in: AccessRouteUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> AccessRoute:
|
||||
await _verify_tool_instance_ownership(instance_id, current_user, session)
|
||||
ar = await session.get(AccessRoute, route_id)
|
||||
if not ar or ar.tool_instance_id != instance_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
|
||||
update_data = ar_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(ar, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ar)
|
||||
return ar
|
||||
|
||||
|
||||
@router.delete("/tool-instances/{instance_id}/access-routes/{route_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
|
||||
async def delete_access_route(
|
||||
instance_id: UUID,
|
||||
route_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
await _verify_tool_instance_ownership(instance_id, current_user, session)
|
||||
ar = await session.get(AccessRoute, route_id)
|
||||
if not ar or ar.tool_instance_id != instance_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Access route not found")
|
||||
await session.delete(ar)
|
||||
await session.commit()
|
||||
@@ -1,122 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.config import Config
|
||||
from app.models.project import Project
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
|
||||
|
||||
router = APIRouter(tags=["configs"])
|
||||
|
||||
|
||||
async def _verify_config_ownership(
|
||||
config_obj: Config, user: User, session: AsyncSession
|
||||
) -> None:
|
||||
if config_obj.scope_type == "user":
|
||||
if config_obj.scope_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif config_obj.scope_type == "project":
|
||||
project = await session.get(Project, config_obj.scope_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif config_obj.scope_type == "tool_instance":
|
||||
ti = await session.get(ToolInstance, config_obj.scope_id)
|
||||
if not ti:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
project = await session.get(Project, ti.project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif config_obj.scope_type == "global":
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
|
||||
|
||||
|
||||
@router.post("/configs", response_model=ConfigRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_config(
|
||||
config_in: ConfigCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Config:
|
||||
cfg = Config(**config_in.model_dump())
|
||||
await _verify_config_ownership(cfg, current_user, session)
|
||||
session.add(cfg)
|
||||
await session.commit()
|
||||
await session.refresh(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.get("/configs", response_model=list[ConfigRead])
|
||||
async def list_configs(
|
||||
scope_type: str | None = None,
|
||||
scope_id: UUID | None = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Config]:
|
||||
stmt = select(Config)
|
||||
if scope_type:
|
||||
stmt = stmt.where(Config.scope_type == scope_type)
|
||||
if scope_id:
|
||||
stmt = stmt.where(Config.scope_id == scope_id)
|
||||
result = await session.execute(stmt)
|
||||
configs = list(result.scalars().all())
|
||||
allowed = []
|
||||
for cfg in configs:
|
||||
try:
|
||||
await _verify_config_ownership(cfg, current_user, session)
|
||||
allowed.append(cfg)
|
||||
except HTTPException:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
|
||||
@router.get("/configs/{config_id}", response_model=ConfigRead)
|
||||
async def get_config(
|
||||
config_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Config:
|
||||
cfg = await session.get(Config, config_id)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
await _verify_config_ownership(cfg, current_user, session)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.put("/configs/{config_id}", response_model=ConfigRead)
|
||||
async def update_config(
|
||||
config_id: UUID,
|
||||
config_in: ConfigUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Config:
|
||||
cfg = await session.get(Config, config_id)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
await _verify_config_ownership(cfg, current_user, session)
|
||||
update_data = config_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(cfg, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.delete("/configs/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config(
|
||||
config_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
cfg = await session.get(Config, config_id)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Config not found")
|
||||
await _verify_config_ownership(cfg, current_user, session)
|
||||
await session.delete(cfg)
|
||||
await session.commit()
|
||||
@@ -1,80 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
|
||||
router = APIRouter(tags=["projects"])
|
||||
|
||||
|
||||
@router.post("/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
project_in: ProjectCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
project = Project(**project_in.model_dump(), owner_id=current_user.id)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectRead])
|
||||
async def list_projects(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Project]:
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.owner_id == current_user.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectRead)
|
||||
async def get_project(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}", response_model=ProjectRead)
|
||||
async def update_project(
|
||||
project_id: UUID,
|
||||
project_in: ProjectUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
update_data = project_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(project, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_project(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
await session.delete(project)
|
||||
await session.commit()
|
||||
@@ -1,100 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.project import Project
|
||||
from app.models.repository import Repository
|
||||
from app.models.user import User
|
||||
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
|
||||
|
||||
router = APIRouter(tags=["repositories"])
|
||||
|
||||
|
||||
async def _get_project_for_user(
|
||||
project_id: UUID, user: User, session: AsyncSession
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/repositories", response_model=RepositoryRead, status_code=status.HTTP_201_CREATED) # noqa: E501
|
||||
async def create_repository(
|
||||
project_id: UUID,
|
||||
repo_in: RepositoryCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Repository:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
repo = Repository(**repo_in.model_dump(), project_id=project_id)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/repositories", response_model=list[RepositoryRead])
|
||||
async def list_repositories(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Repository]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
result = await session.execute(
|
||||
select(Repository).where(Repository.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
|
||||
async def get_repository(
|
||||
project_id: UUID,
|
||||
repo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Repository:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
repo = await session.get(Repository, repo_id)
|
||||
if not repo or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/repositories/{repo_id}", response_model=RepositoryRead)
|
||||
async def update_repository(
|
||||
project_id: UUID,
|
||||
repo_id: UUID,
|
||||
repo_in: RepositoryUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Repository:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
repo = await session.get(Repository, repo_id)
|
||||
if not repo or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
|
||||
update_data = repo_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(repo, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
|
||||
async def delete_repository(
|
||||
project_id: UUID,
|
||||
repo_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
repo = await session.get(Repository, repo_id)
|
||||
if not repo or repo.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
|
||||
await session.delete(repo)
|
||||
await session.commit()
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Repository connection router."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.git.credential_storage import DatabaseCredentialStorage
|
||||
from app.git.credentials import AccessTokenCredential, GitCredential
|
||||
from app.git.providers import get_provider
|
||||
from app.git.ssh_key import SshKeyLifecycle
|
||||
from app.git.types import ConnectionStatus, ProviderKind
|
||||
from app.models.project import Project
|
||||
from app.models.repository import Repository
|
||||
from app.models.repository_connection import RepositoryConnection
|
||||
from app.models.user import User
|
||||
from app.schemas.repository_connection import (
|
||||
RepositoryConnectionCreate,
|
||||
RepositoryConnectionRead,
|
||||
SshKeyResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["repository-connections"])
|
||||
|
||||
|
||||
async def _get_project_for_user(
|
||||
project_id: UUID, user: User, session: AsyncSession
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/repository-connections",
|
||||
response_model=RepositoryConnectionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_repository_connection(
|
||||
project_id: UUID,
|
||||
conn_in: RepositoryConnectionCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> RepositoryConnection:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
|
||||
repo = await session.get(Repository, conn_in.repository_id)
|
||||
if not repo or repo.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(RepositoryConnection).where(
|
||||
RepositoryConnection.project_id == project_id,
|
||||
RepositoryConnection.repository_id == conn_in.repository_id,
|
||||
RepositoryConnection.provider_kind == conn_in.provider_kind,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Connection already exists for this repository and provider",
|
||||
)
|
||||
|
||||
storage = DatabaseCredentialStorage(session)
|
||||
credential: GitCredential
|
||||
if conn_in.credential_kind == "access_token":
|
||||
credential = AccessTokenCredential(
|
||||
encrypted_payload=conn_in.credential_payload
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported credential kind: {conn_in.credential_kind}",
|
||||
)
|
||||
|
||||
credential_id = await storage.create(credential)
|
||||
|
||||
connection = RepositoryConnection(
|
||||
project_id=project_id,
|
||||
repository_id=conn_in.repository_id,
|
||||
provider_kind=conn_in.provider_kind,
|
||||
credential_id=credential_id,
|
||||
connection_status=str(ConnectionStatus.pending),
|
||||
)
|
||||
session.add(connection)
|
||||
await session.commit()
|
||||
await session.refresh(connection)
|
||||
|
||||
try:
|
||||
provider = get_provider(ProviderKind(conn_in.provider_kind))
|
||||
provider_status = provider.validate_connection(repo.git_url, str(credential_id))
|
||||
connection.connection_status = str(provider_status)
|
||||
except Exception:
|
||||
connection.connection_status = str(ConnectionStatus.error)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(connection)
|
||||
return connection
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/repository-connections",
|
||||
response_model=list[RepositoryConnectionRead],
|
||||
)
|
||||
async def list_repository_connections(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[RepositoryConnection]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
result = await session.execute(
|
||||
select(RepositoryConnection).where(
|
||||
RepositoryConnection.project_id == project_id
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/repository-connections/{connection_id}",
|
||||
response_model=RepositoryConnectionRead,
|
||||
)
|
||||
async def get_repository_connection(
|
||||
project_id: UUID,
|
||||
connection_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> RepositoryConnection:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
connection = await session.get(RepositoryConnection, connection_id)
|
||||
if not connection or connection.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/projects/{project_id}/repository-connections/{connection_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_repository_connection(
|
||||
project_id: UUID,
|
||||
connection_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
connection = await session.get(RepositoryConnection, connection_id)
|
||||
if not connection or connection.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||
)
|
||||
|
||||
if connection.credential_id:
|
||||
storage = DatabaseCredentialStorage(session)
|
||||
await storage.delete(connection.credential_id)
|
||||
|
||||
await session.delete(connection)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/repository-connections/{connection_id}/ssh-key",
|
||||
response_model=SshKeyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def generate_ssh_key(
|
||||
project_id: UUID,
|
||||
connection_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
connection = await session.get(RepositoryConnection, connection_id)
|
||||
if not connection or connection.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||
)
|
||||
|
||||
key_pair = SshKeyLifecycle.generate(connection_id)
|
||||
|
||||
storage = DatabaseCredentialStorage(session)
|
||||
ssh_credential = GitCredential(
|
||||
kind="ssh_key",
|
||||
encrypted_payload=key_pair.encrypted_private_key,
|
||||
)
|
||||
credential_id = await storage.create(ssh_credential)
|
||||
|
||||
connection.credential_id = credential_id
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"connection_id": str(connection_id),
|
||||
"public_key": key_pair.public_key,
|
||||
"credential_id": str(credential_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/repository-connections/{connection_id}/validate",
|
||||
response_model=RepositoryConnectionRead,
|
||||
)
|
||||
async def validate_connection(
|
||||
project_id: UUID,
|
||||
connection_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> RepositoryConnection:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
connection = await session.get(RepositoryConnection, connection_id)
|
||||
if not connection or connection.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found"
|
||||
)
|
||||
|
||||
repo = await session.get(Repository, connection.repository_id)
|
||||
if not repo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found"
|
||||
)
|
||||
|
||||
try:
|
||||
provider = get_provider(ProviderKind(connection.provider_kind))
|
||||
provider_status = provider.validate_connection(
|
||||
repo.git_url, str(connection.credential_id) if connection.credential_id else ""
|
||||
)
|
||||
connection.connection_status = str(provider_status)
|
||||
except Exception:
|
||||
connection.connection_status = str(ConnectionStatus.error)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(connection)
|
||||
return connection
|
||||
@@ -1,129 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.encryption import encrypt_value
|
||||
from app.models.project import Project
|
||||
from app.models.secret import Secret
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
|
||||
|
||||
router = APIRouter(tags=["secrets"])
|
||||
|
||||
|
||||
async def _verify_secret_ownership(
|
||||
secret_obj: Secret, user: User, session: AsyncSession
|
||||
) -> None:
|
||||
if secret_obj.scope_type == "user":
|
||||
if secret_obj.scope_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif secret_obj.scope_type == "project":
|
||||
project = await session.get(Project, secret_obj.scope_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif secret_obj.scope_type == "tool_instance":
|
||||
ti = await session.get(ToolInstance, secret_obj.scope_id)
|
||||
if not ti:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
project = await session.get(Project, ti.project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
elif secret_obj.scope_type == "global":
|
||||
pass
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid scope_type")
|
||||
|
||||
|
||||
@router.post("/secrets", response_model=SecretRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_secret(
|
||||
secret_in: SecretCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SecretRead:
|
||||
secret = Secret(
|
||||
scope_type=secret_in.scope_type,
|
||||
scope_id=secret_in.scope_id,
|
||||
key=secret_in.key,
|
||||
encrypted_value=encrypt_value(secret_in.value),
|
||||
)
|
||||
await _verify_secret_ownership(secret, current_user, session)
|
||||
session.add(secret)
|
||||
await session.commit()
|
||||
await session.refresh(secret)
|
||||
return SecretRead.from_secret(secret)
|
||||
|
||||
|
||||
@router.get("/secrets", response_model=list[SecretRead])
|
||||
async def list_secrets(
|
||||
scope_type: str | None = None,
|
||||
scope_id: UUID | None = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[SecretRead]:
|
||||
stmt = select(Secret)
|
||||
if scope_type:
|
||||
stmt = stmt.where(Secret.scope_type == scope_type)
|
||||
if scope_id:
|
||||
stmt = stmt.where(Secret.scope_id == scope_id)
|
||||
result = await session.execute(stmt)
|
||||
secrets = list(result.scalars().all())
|
||||
allowed = []
|
||||
for s in secrets:
|
||||
try:
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
allowed.append(SecretRead.from_secret(s))
|
||||
except HTTPException:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
|
||||
@router.get("/secrets/{secret_id}", response_model=SecretRead)
|
||||
async def get_secret(
|
||||
secret_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SecretRead:
|
||||
s = await session.get(Secret, secret_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
return SecretRead.from_secret(s)
|
||||
|
||||
|
||||
@router.put("/secrets/{secret_id}", response_model=SecretRead)
|
||||
async def update_secret(
|
||||
secret_id: UUID,
|
||||
secret_in: SecretUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> SecretRead:
|
||||
s = await session.get(Secret, secret_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
if secret_in.key is not None:
|
||||
s.key = secret_in.key
|
||||
if secret_in.value is not None:
|
||||
s.encrypted_value = encrypt_value(secret_in.value)
|
||||
await session.commit()
|
||||
await session.refresh(s)
|
||||
return SecretRead.from_secret(s)
|
||||
|
||||
|
||||
@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_secret(
|
||||
secret_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
s = await session.get(Secret, secret_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
await session.delete(s)
|
||||
await session.commit()
|
||||
@@ -1,88 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.tool_definition import ToolDefinition
|
||||
from app.models.user import User
|
||||
from app.schemas.tool_definition import (
|
||||
ToolDefinitionCreate,
|
||||
ToolDefinitionRead,
|
||||
ToolDefinitionUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["tool-definitions"])
|
||||
|
||||
|
||||
@router.post("/tool-definitions", response_model=ToolDefinitionRead, status_code=status.HTTP_201_CREATED) # noqa: E501
|
||||
async def create_tool_definition(
|
||||
td_in: ToolDefinitionCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolDefinition:
|
||||
td = ToolDefinition(**td_in.model_dump())
|
||||
session.add(td)
|
||||
await session.commit()
|
||||
await session.refresh(td)
|
||||
return td
|
||||
|
||||
|
||||
@router.get("/tool-definitions", response_model=list[ToolDefinitionRead])
|
||||
async def list_tool_definitions(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[ToolDefinition]:
|
||||
result = await session.execute(select(ToolDefinition))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
|
||||
async def get_tool_definition(
|
||||
tool_def_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolDefinition:
|
||||
td = await session.get(ToolDefinition, tool_def_id)
|
||||
if not td:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
|
||||
)
|
||||
return td
|
||||
|
||||
|
||||
@router.put("/tool-definitions/{tool_def_id}", response_model=ToolDefinitionRead)
|
||||
async def update_tool_definition(
|
||||
tool_def_id: UUID,
|
||||
td_in: ToolDefinitionUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolDefinition:
|
||||
td = await session.get(ToolDefinition, tool_def_id)
|
||||
if not td:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
|
||||
)
|
||||
update_data = td_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(td, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(td)
|
||||
return td
|
||||
|
||||
|
||||
@router.delete("/tool-definitions/{tool_def_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tool_definition(
|
||||
tool_def_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
td = await session.get(ToolDefinition, tool_def_id)
|
||||
if not td:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Tool definition not found"
|
||||
)
|
||||
await session.delete(td)
|
||||
await session.commit()
|
||||
@@ -1,327 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.config import settings
|
||||
from app.db import get_db_session
|
||||
from app.models.project import Project
|
||||
from app.models.tool_definition import ToolDefinition
|
||||
from app.models.tool_instance import ToolInstance
|
||||
from app.models.user import User
|
||||
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
|
||||
from app.services.spawn import SpawnError, SpawnService
|
||||
from app.services.traefik import TraefikLabelGenerator
|
||||
from app.tools.registry import registry
|
||||
|
||||
router = APIRouter(tags=["tool-instances"])
|
||||
|
||||
|
||||
async def _get_project_for_user(
|
||||
project_id: UUID, user: User, session: AsyncSession
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
def _get_user_slug(user: User) -> str:
|
||||
user_slug = (
|
||||
user.display_name
|
||||
or user.email.split("@")[0]
|
||||
if user.email
|
||||
else "user"
|
||||
)
|
||||
return user_slug.lower().replace(" ", "-").replace("_", "-")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/tool-instances",
|
||||
response_model=ToolInstanceRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_tool_instance(
|
||||
project_id: UUID,
|
||||
ti_in: ToolInstanceCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolInstance:
|
||||
project = await _get_project_for_user(project_id, current_user, session)
|
||||
tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id)
|
||||
if not tool_def:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool definition not found",
|
||||
)
|
||||
|
||||
manifest = registry.get(tool_def.key)
|
||||
if not manifest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool manifest '{tool_def.key}' not found in registry",
|
||||
)
|
||||
|
||||
existing = await session.execute(
|
||||
select(ToolInstance).where(
|
||||
ToolInstance.project_id == project_id,
|
||||
ToolInstance.tool_definition_id == ti_in.tool_definition_id,
|
||||
ToolInstance.status.in_(["creating", "running"]),
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A running instance of this tool already exists for this project",
|
||||
)
|
||||
|
||||
ti = ToolInstance(**ti_in.model_dump(), project_id=project_id)
|
||||
user_slug = _get_user_slug(current_user)
|
||||
|
||||
spawn_service = SpawnService()
|
||||
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
|
||||
|
||||
try:
|
||||
spawn_result = spawn_service.spawn(
|
||||
instance_id=str(ti.id),
|
||||
manifest=manifest,
|
||||
project_slug=project.slug,
|
||||
user_slug=user_slug,
|
||||
)
|
||||
except SpawnError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to spawn container: {e}",
|
||||
) from e
|
||||
|
||||
auth_labels = label_gen.generate_forward_auth_labels(
|
||||
instance_id=str(ti.id),
|
||||
auth_url=f"https://{settings.root_domain}/api/v1/auth/validate",
|
||||
)
|
||||
traefik_labels = {**spawn_result["traefik_labels"], **auth_labels}
|
||||
|
||||
ti.container_id = spawn_result["container_id"]
|
||||
ti.subdomain = spawn_result["subdomain"]
|
||||
ti.traefik_labels = traefik_labels
|
||||
ti.status = spawn_service.get_status(str(ti.id))
|
||||
|
||||
session.add(ti)
|
||||
await session.commit()
|
||||
await session.refresh(ti)
|
||||
return ti
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/tool-instances",
|
||||
response_model=list[ToolInstanceRead],
|
||||
)
|
||||
async def list_tool_instances(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[ToolInstance]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
result = await session.execute(
|
||||
select(ToolInstance).where(ToolInstance.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}",
|
||||
response_model=ToolInstanceRead,
|
||||
)
|
||||
async def get_tool_instance(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolInstance:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
return ti
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}",
|
||||
response_model=ToolInstanceRead,
|
||||
)
|
||||
async def update_tool_instance(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
ti_in: ToolInstanceUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolInstance:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
update_data = ti_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(ti, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ti)
|
||||
return ti
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_tool_instance(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
|
||||
spawn_service = SpawnService()
|
||||
spawn_service.stop(str(instance_id))
|
||||
|
||||
await session.delete(ti)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}/stop",
|
||||
response_model=ToolInstanceRead,
|
||||
)
|
||||
async def stop_tool_instance(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolInstance:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
|
||||
spawn_service = SpawnService()
|
||||
spawn_service.stop(str(instance_id))
|
||||
|
||||
ti.status = "stopped"
|
||||
ti.container_id = None
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(ti)
|
||||
return ti
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}/start",
|
||||
response_model=ToolInstanceRead,
|
||||
)
|
||||
async def start_tool_instance(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> ToolInstance:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
|
||||
tool_def = await session.get(ToolDefinition, ti.tool_definition_id)
|
||||
if not tool_def:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool definition not found",
|
||||
)
|
||||
|
||||
manifest = registry.get(tool_def.key)
|
||||
if not manifest:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool manifest '{tool_def.key}' not found in registry",
|
||||
)
|
||||
|
||||
user_slug = _get_user_slug(current_user)
|
||||
spawn_service = SpawnService()
|
||||
|
||||
try:
|
||||
spawn_result = spawn_service.spawn(
|
||||
instance_id=str(ti.id),
|
||||
manifest=manifest,
|
||||
project_slug=ti.project.slug,
|
||||
user_slug=user_slug,
|
||||
)
|
||||
except SpawnError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to spawn container: {e}",
|
||||
) from e
|
||||
|
||||
ti.container_id = spawn_result["container_id"]
|
||||
ti.subdomain = spawn_result["subdomain"]
|
||||
ti.traefik_labels = spawn_result["traefik_labels"]
|
||||
ti.status = spawn_service.get_status(str(ti.id))
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(ti)
|
||||
return ti
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/tool-instances/{instance_id}/status",
|
||||
response_model=dict,
|
||||
)
|
||||
async def get_tool_instance_status(
|
||||
project_id: UUID,
|
||||
instance_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ti = await session.get(ToolInstance, instance_id)
|
||||
if not ti or ti.project_id != project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tool instance not found",
|
||||
)
|
||||
|
||||
spawn_service = SpawnService()
|
||||
container_status = spawn_service.get_status(str(instance_id))
|
||||
|
||||
if ti.status != container_status:
|
||||
ti.status = container_status
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"instance_id": str(instance_id),
|
||||
"status": container_status,
|
||||
"subdomain": ti.subdomain or "",
|
||||
"container_id": ti.container_id or "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/auth/validate", status_code=status.HTTP_200_OK)
|
||||
async def validate_auth_for_traefik(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> dict[str, str]:
|
||||
return {"status": "ok", "user_id": str(current_user.id)}
|
||||
@@ -1,17 +0,0 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRead
|
||||
|
||||
router = APIRouter(tags=["users"])
|
||||
|
||||
|
||||
@router.get("/users/me", response_model=UserRead)
|
||||
async def read_current_user(current_user: User = Depends(get_current_active_user)) -> User:
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserRead])
|
||||
async def list_users(current_user: User = Depends(get_current_active_user)) -> list[User]:
|
||||
return [current_user]
|
||||
@@ -1,100 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
|
||||
|
||||
router = APIRouter(tags=["workspaces"])
|
||||
|
||||
|
||||
async def _get_project_for_user(
|
||||
project_id: UUID, user: User, session: AsyncSession
|
||||
) -> Project:
|
||||
project = await session.get(Project, project_id)
|
||||
if not project or project.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/workspaces", response_model=WorkspaceRead, status_code=status.HTTP_201_CREATED) # noqa: E501
|
||||
async def create_workspace(
|
||||
project_id: UUID,
|
||||
ws_in: WorkspaceCreate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Workspace:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ws = Workspace(**ws_in.model_dump(), project_id=project_id)
|
||||
session.add(ws)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/workspaces", response_model=list[WorkspaceRead])
|
||||
async def list_workspaces(
|
||||
project_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> list[Workspace]:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
result = await session.execute(
|
||||
select(Workspace).where(Workspace.project_id == project_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
|
||||
async def get_workspace(
|
||||
project_id: UUID,
|
||||
ws_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Workspace:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ws = await session.get(Workspace, ws_id)
|
||||
if not ws or ws.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
return ws
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/workspaces/{ws_id}", response_model=WorkspaceRead)
|
||||
async def update_workspace(
|
||||
project_id: UUID,
|
||||
ws_id: UUID,
|
||||
ws_in: WorkspaceUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> Workspace:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ws = await session.get(Workspace, ws_id)
|
||||
if not ws or ws.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
update_data = ws_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(ws, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/workspaces/{ws_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
project_id: UUID,
|
||||
ws_id: UUID,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
await _get_project_for_user(project_id, current_user, session)
|
||||
ws = await session.get(Workspace, ws_id)
|
||||
if not ws or ws.project_id != project_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
await session.delete(ws)
|
||||
await session.commit()
|
||||
@@ -1,42 +0,0 @@
|
||||
from app.schemas.access_route import AccessRouteCreate, AccessRouteRead, AccessRouteUpdate
|
||||
from app.schemas.config import ConfigCreate, ConfigRead, ConfigUpdate
|
||||
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from app.schemas.repository import RepositoryCreate, RepositoryRead, RepositoryUpdate
|
||||
from app.schemas.secret import SecretCreate, SecretRead, SecretUpdate
|
||||
from app.schemas.tool_definition import (
|
||||
ToolDefinitionCreate,
|
||||
ToolDefinitionRead,
|
||||
ToolDefinitionUpdate,
|
||||
)
|
||||
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
|
||||
|
||||
__all__ = [
|
||||
"AccessRouteCreate",
|
||||
"AccessRouteRead",
|
||||
"AccessRouteUpdate",
|
||||
"ConfigCreate",
|
||||
"ConfigRead",
|
||||
"ConfigUpdate",
|
||||
"ProjectCreate",
|
||||
"ProjectRead",
|
||||
"ProjectUpdate",
|
||||
"RepositoryCreate",
|
||||
"RepositoryRead",
|
||||
"RepositoryUpdate",
|
||||
"SecretCreate",
|
||||
"SecretRead",
|
||||
"SecretUpdate",
|
||||
"ToolDefinitionCreate",
|
||||
"ToolDefinitionRead",
|
||||
"ToolDefinitionUpdate",
|
||||
"ToolInstanceCreate",
|
||||
"ToolInstanceRead",
|
||||
"ToolInstanceUpdate",
|
||||
"UserCreate",
|
||||
"UserRead",
|
||||
"WorkspaceCreate",
|
||||
"WorkspaceRead",
|
||||
"WorkspaceUpdate",
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class AccessRouteBase(OrmBase):
|
||||
domain: str
|
||||
path_prefix: str = "/"
|
||||
provider_type: str = "traefik"
|
||||
provider_config: dict[str, Any] | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AccessRouteCreate(AccessRouteBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccessRouteRead(AccessRouteBase):
|
||||
id: UUID
|
||||
tool_instance_id: UUID
|
||||
|
||||
|
||||
class AccessRouteUpdate(OrmBase):
|
||||
domain: str | None = None
|
||||
path_prefix: str | None = None
|
||||
provider_type: str | None = None
|
||||
provider_config: dict[str, Any] | None = None
|
||||
is_active: bool | None = None
|
||||
@@ -1,5 +0,0 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class OrmBase(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,25 +0,0 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class ConfigBase(OrmBase):
|
||||
scope_type: str
|
||||
scope_id: UUID
|
||||
tool_definition_id: UUID | None = None
|
||||
key: str
|
||||
value: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigCreate(ConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigRead(ConfigBase):
|
||||
id: UUID
|
||||
|
||||
|
||||
class ConfigUpdate(OrmBase):
|
||||
key: str | None = None
|
||||
value: dict[str, Any] | None = None
|
||||
@@ -1,24 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class ProjectBase(OrmBase):
|
||||
name: str
|
||||
slug: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProjectRead(ProjectBase):
|
||||
id: UUID
|
||||
owner_id: UUID
|
||||
|
||||
|
||||
class ProjectUpdate(OrmBase):
|
||||
name: str | None = None
|
||||
slug: str | None = None
|
||||
description: str | None = None
|
||||
@@ -1,26 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class RepositoryBase(OrmBase):
|
||||
name: str
|
||||
git_url: str
|
||||
provider_type: str = "generic"
|
||||
default_branch: str = "main"
|
||||
|
||||
|
||||
class RepositoryCreate(RepositoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class RepositoryRead(RepositoryBase):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
|
||||
|
||||
class RepositoryUpdate(OrmBase):
|
||||
name: str | None = None
|
||||
git_url: str | None = None
|
||||
provider_type: str | None = None
|
||||
default_branch: str | None = None
|
||||
@@ -1,35 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class RepositoryConnectionBase(OrmBase):
|
||||
project_id: UUID
|
||||
repository_id: UUID | None = None
|
||||
provider_kind: str = "generic"
|
||||
credential_id: UUID | None = None
|
||||
connection_status: str = "pending"
|
||||
default_branch: str | None = None
|
||||
|
||||
|
||||
class RepositoryConnectionCreate(OrmBase):
|
||||
repository_id: UUID
|
||||
provider_kind: str
|
||||
credential_kind: str
|
||||
credential_payload: str
|
||||
|
||||
|
||||
class RepositoryConnectionRead(OrmBase):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
repository_id: UUID | None = None
|
||||
provider_kind: str
|
||||
credential_id: UUID | None = None
|
||||
connection_status: str
|
||||
default_branch: str | None = None
|
||||
|
||||
|
||||
class SshKeyResponse(OrmBase):
|
||||
connection_id: UUID
|
||||
public_key: str
|
||||
credential_id: UUID
|
||||
@@ -1,39 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.secret import Secret
|
||||
|
||||
|
||||
class SecretBase(OrmBase):
|
||||
scope_type: str
|
||||
scope_id: UUID
|
||||
key: str
|
||||
|
||||
|
||||
class SecretCreate(SecretBase):
|
||||
value: str
|
||||
|
||||
|
||||
class SecretRead(SecretBase):
|
||||
id: UUID
|
||||
value: str = "••••••"
|
||||
|
||||
@classmethod
|
||||
def from_secret(cls, secret: Secret) -> SecretRead:
|
||||
return cls(
|
||||
id=secret.id,
|
||||
scope_type=secret.scope_type,
|
||||
scope_id=secret.scope_id,
|
||||
key=secret.key,
|
||||
value="••••••",
|
||||
)
|
||||
|
||||
|
||||
class SecretUpdate(OrmBase):
|
||||
key: str | None = None
|
||||
value: str | None = None
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class ToolDefinitionBase(OrmBase):
|
||||
key: str
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
description: str | None = None
|
||||
image: str
|
||||
manifest_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ToolDefinitionCreate(ToolDefinitionBase):
|
||||
pass
|
||||
|
||||
|
||||
class ToolDefinitionRead(ToolDefinitionBase):
|
||||
id: UUID
|
||||
|
||||
|
||||
class ToolDefinitionUpdate(OrmBase):
|
||||
name: str | None = None
|
||||
version: str | None = None
|
||||
description: str | None = None
|
||||
image: str | None = None
|
||||
manifest_data: dict[str, Any] | None = None
|
||||
@@ -1,32 +0,0 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class ToolInstanceBase(OrmBase):
|
||||
name: str
|
||||
status: str = "pending"
|
||||
container_id: str | None = None
|
||||
subdomain: str | None = None
|
||||
config_override: dict[str, Any] | None = None
|
||||
traefik_labels: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ToolInstanceCreate(ToolInstanceBase):
|
||||
tool_definition_id: UUID
|
||||
|
||||
|
||||
class ToolInstanceRead(ToolInstanceBase):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
tool_definition_id: UUID
|
||||
|
||||
|
||||
class ToolInstanceUpdate(OrmBase):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
container_id: str | None = None
|
||||
subdomain: str | None = None
|
||||
config_override: dict[str, Any] | None = None
|
||||
traefik_labels: dict[str, Any] | None = None
|
||||
@@ -1,21 +0,0 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class UserBase(OrmBase):
|
||||
authentik_sub: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,22 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
|
||||
class WorkspaceBase(OrmBase):
|
||||
name: str
|
||||
mount_path: str | None = None
|
||||
|
||||
|
||||
class WorkspaceCreate(WorkspaceBase):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceRead(WorkspaceBase):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
|
||||
|
||||
class WorkspaceUpdate(OrmBase):
|
||||
name: str | None = None
|
||||
mount_path: str | None = None
|
||||
@@ -1,135 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.encryption import decrypt_value
|
||||
from app.models.config import Config
|
||||
from app.models.secret import Secret
|
||||
|
||||
|
||||
class RuntimeInjectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeInjectionService:
|
||||
SCOPE_HIERARCHY = ["global", "user", "project", "tool_instance"]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_configs(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
tool_definition_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(Config).where(
|
||||
|
||||
(Config.scope_type == "global")
|
||||
| (
|
||||
(Config.scope_type == "user")
|
||||
& (Config.scope_id == user_id)
|
||||
)
|
||||
| (
|
||||
(Config.scope_type == "project")
|
||||
& (Config.scope_id == project_id)
|
||||
)
|
||||
| (
|
||||
(Config.scope_type == "tool_instance")
|
||||
& (Config.scope_id == (instance_id or uuid.UUID(int=0)))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
if tool_definition_id:
|
||||
stmt = stmt.where(
|
||||
(Config.tool_definition_id == tool_definition_id)
|
||||
| (Config.tool_definition_id.is_(None))
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
configs = list(result.scalars().all())
|
||||
|
||||
resolved: dict[str, Any] = {}
|
||||
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
|
||||
for cfg in configs:
|
||||
if cfg.scope_type == scope:
|
||||
resolved[cfg.key] = cfg.value
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
async def resolve_secrets(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
) -> dict[str, str]:
|
||||
stmt = select(Secret).where(
|
||||
|
||||
(Secret.scope_type == "global")
|
||||
| (
|
||||
(Secret.scope_type == "user")
|
||||
& (Secret.scope_id == user_id)
|
||||
)
|
||||
| (
|
||||
(Secret.scope_type == "project")
|
||||
& (Secret.scope_id == project_id)
|
||||
)
|
||||
| (
|
||||
(Secret.scope_type == "tool_instance")
|
||||
& (Secret.scope_id == (instance_id or uuid.UUID(int=0)))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
secrets = list(result.scalars().all())
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
|
||||
for secret in secrets:
|
||||
if secret.scope_type == scope:
|
||||
resolved[secret.key] = decrypt_value(secret.encrypted_value)
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def generate_config_files(configs: dict[str, Any], config_dir: Path) -> list[str]:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
mounts = []
|
||||
|
||||
for key, value in configs.items():
|
||||
file_path = config_dir / f"{key}.json"
|
||||
file_path.write_text(json.dumps(value, indent=2))
|
||||
file_path.chmod(0o400)
|
||||
mounts.append(f"{file_path}:/app/config/{key}.json:ro")
|
||||
|
||||
return mounts
|
||||
|
||||
@staticmethod
|
||||
def generate_secret_env_vars(secrets: dict[str, str]) -> dict[str, str]:
|
||||
return {key.upper(): value for key, value in secrets.items()}
|
||||
|
||||
@staticmethod
|
||||
async def validate_secrets_exist(
|
||||
session: AsyncSession,
|
||||
required_secret_keys: list[str],
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
resolved = await RuntimeInjectionService.resolve_secrets(
|
||||
session, project_id, user_id, instance_id
|
||||
)
|
||||
|
||||
missing = [key for key in required_secret_keys if key not in resolved]
|
||||
if missing:
|
||||
raise RuntimeInjectionError(
|
||||
f"Missing required secrets: {', '.join(missing)}"
|
||||
)
|
||||
@@ -1,345 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.services.traefik import TraefikLabelGenerator
|
||||
from app.tools.models import ToolManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpawnError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SpawnService:
|
||||
def __init__(
|
||||
self,
|
||||
compose_dir: Path | None = None,
|
||||
network_name: str = "tools",
|
||||
) -> None:
|
||||
self.compose_dir = compose_dir or Path("/tmp/headquarter-compose")
|
||||
self.network_name = network_name
|
||||
self.compose_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _generate_compose_service(
|
||||
self,
|
||||
instance_id: str,
|
||||
manifest: ToolManifest,
|
||||
subdomain: str,
|
||||
traefik_labels: dict[str, str],
|
||||
project_slug: str,
|
||||
user_slug: str,
|
||||
workspace_path: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
ssh_key_path: Path | None = None,
|
||||
config_mounts: list[str] | None = None,
|
||||
secret_env_vars: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
service_name = f"tool-{instance_id[:8]}"
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": manifest.image,
|
||||
"container_name": service_name,
|
||||
"restart": "unless-stopped",
|
||||
"labels": traefik_labels,
|
||||
"networks": [self.network_name],
|
||||
}
|
||||
|
||||
if manifest.runtime_command:
|
||||
service["command"] = manifest.runtime_command
|
||||
|
||||
if manifest.runtime_entrypoint:
|
||||
service["entrypoint"] = manifest.runtime_entrypoint
|
||||
|
||||
if manifest.runtime_user:
|
||||
service["user"] = manifest.runtime_user
|
||||
|
||||
if manifest.runtime_working_dir:
|
||||
service["working_dir"] = manifest.runtime_working_dir
|
||||
|
||||
ports = manifest.ports
|
||||
if ports:
|
||||
service["ports"] = [
|
||||
f"{port.container_port}:{port.container_port}"
|
||||
for port in ports
|
||||
]
|
||||
|
||||
env = dict(manifest.env)
|
||||
env.update({
|
||||
"PROJECT_SLUG": project_slug,
|
||||
"USER_SLUG": user_slug,
|
||||
})
|
||||
service["environment"] = env
|
||||
|
||||
volumes: list[str] = []
|
||||
|
||||
default_workspace = f"/data/workspaces/{user_slug}/{project_slug}"
|
||||
for mount in manifest.workspace_mounts:
|
||||
source = mount.source_pattern.format(
|
||||
project_repo=str(workspace_path) if workspace_path else default_workspace,
|
||||
)
|
||||
ro_suffix = ":ro" if mount.read_only else ""
|
||||
volumes.append(f"{source}:{mount.target}{ro_suffix}")
|
||||
|
||||
default_config = f"/data/configs/{user_slug}"
|
||||
for mount in manifest.config_mounts:
|
||||
source = mount.source_pattern.format(
|
||||
user_config=str(config_path) if config_path else default_config,
|
||||
)
|
||||
ro_suffix = ":ro" if mount.read_only else ""
|
||||
volumes.append(f"{source}:{mount.target}{ro_suffix}")
|
||||
|
||||
if ssh_key_path and ssh_key_path.exists():
|
||||
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
|
||||
|
||||
if config_mounts:
|
||||
volumes.extend(config_mounts)
|
||||
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
if secret_env_vars:
|
||||
service["environment"].update(secret_env_vars)
|
||||
|
||||
if manifest.health_check:
|
||||
hc = manifest.health_check
|
||||
healthcheck: dict[str, Any] = {
|
||||
"interval": f"{hc.interval_seconds}s",
|
||||
"timeout": f"{hc.timeout_seconds}s",
|
||||
"retries": hc.retries,
|
||||
"start_period": f"{hc.start_period_seconds}s",
|
||||
}
|
||||
|
||||
if hc.type == "http":
|
||||
healthcheck["test"] = [
|
||||
"CMD",
|
||||
"curl",
|
||||
"-f",
|
||||
f"http://localhost:{hc.port}{hc.path}",
|
||||
]
|
||||
elif hc.type == "tcp":
|
||||
healthcheck["test"] = [
|
||||
"CMD",
|
||||
"nc",
|
||||
"-z",
|
||||
"localhost",
|
||||
str(hc.port),
|
||||
]
|
||||
elif hc.type == "command":
|
||||
healthcheck["test"] = ["CMD"] + (hc.command or [])
|
||||
|
||||
service["healthcheck"] = healthcheck
|
||||
|
||||
if manifest.resource_limits:
|
||||
rl = manifest.resource_limits
|
||||
deploy: dict[str, Any] = {"resources": {"limits": {}}}
|
||||
if rl.cpus:
|
||||
deploy["resources"]["limits"]["cpus"] = str(rl.cpus)
|
||||
if rl.memory_mb:
|
||||
deploy["resources"]["limits"]["memory"] = f"{rl.memory_mb}M"
|
||||
if rl.memory_swap_mb is not None and rl.memory_swap_mb >= 0:
|
||||
deploy["resources"]["limits"]["swap"] = f"{rl.memory_swap_mb}M"
|
||||
service["deploy"] = deploy
|
||||
|
||||
return service
|
||||
|
||||
def _write_compose_file(
|
||||
self,
|
||||
instance_id: str,
|
||||
service: dict[str, Any],
|
||||
) -> Path:
|
||||
compose_path = self.compose_dir / f"{instance_id}.yml"
|
||||
|
||||
compose = {
|
||||
"version": "3.8",
|
||||
"services": {f"tool-{instance_id[:8]}": service},
|
||||
"networks": {
|
||||
self.network_name: {
|
||||
"external": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
compose_path.write_text(json.dumps(compose, indent=2))
|
||||
return compose_path
|
||||
|
||||
def spawn(
|
||||
self,
|
||||
instance_id: str,
|
||||
manifest: ToolManifest,
|
||||
project_slug: str,
|
||||
user_slug: str,
|
||||
workspace_path: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
ssh_key_path: Path | None = None,
|
||||
config_mounts: list[str] | None = None,
|
||||
secret_env_vars: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
|
||||
|
||||
primary_port = next(
|
||||
(p.container_port for p in manifest.ports if p.primary),
|
||||
manifest.ports[0].container_port if manifest.ports else 8080,
|
||||
)
|
||||
|
||||
subdomain = label_gen.generate_subdomain(
|
||||
tool_key=manifest.id,
|
||||
project_slug=project_slug,
|
||||
user_slug=user_slug,
|
||||
)
|
||||
|
||||
traefik_labels = label_gen.generate_labels(
|
||||
instance_id=instance_id,
|
||||
tool_key=manifest.id,
|
||||
project_slug=project_slug,
|
||||
user_slug=user_slug,
|
||||
container_port=primary_port,
|
||||
network_name=self.network_name,
|
||||
)
|
||||
|
||||
service = self._generate_compose_service(
|
||||
instance_id=instance_id,
|
||||
manifest=manifest,
|
||||
subdomain=subdomain,
|
||||
traefik_labels=traefik_labels,
|
||||
project_slug=project_slug,
|
||||
user_slug=user_slug,
|
||||
workspace_path=workspace_path,
|
||||
config_path=config_path,
|
||||
ssh_key_path=ssh_key_path,
|
||||
config_mounts=config_mounts,
|
||||
secret_env_vars=secret_env_vars,
|
||||
)
|
||||
|
||||
compose_path = self._write_compose_file(instance_id, service)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose",
|
||||
"-f", str(compose_path),
|
||||
"-p", f"hq-tool-{instance_id[:8]}",
|
||||
"up", "-d", "--remove-orphans",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.info("Spawned container for instance %s: %s", instance_id, result.stdout)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("Failed to spawn container for instance %s: %s", instance_id, e.stderr)
|
||||
raise SpawnError(f"Failed to spawn container: {e.stderr}") from e
|
||||
|
||||
container_id = self._get_container_id(instance_id)
|
||||
|
||||
return {
|
||||
"container_id": container_id,
|
||||
"subdomain": subdomain,
|
||||
"traefik_labels": traefik_labels,
|
||||
"compose_path": str(compose_path),
|
||||
}
|
||||
|
||||
def stop(self, instance_id: str) -> None:
|
||||
compose_path = self.compose_dir / f"{instance_id}.yml"
|
||||
|
||||
if not compose_path.exists():
|
||||
logger.warning("Compose file not found for instance %s", instance_id)
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "compose",
|
||||
"-f", str(compose_path),
|
||||
"-p", f"hq-tool-{instance_id[:8]}",
|
||||
"down",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
logger.info("Stopped container for instance %s", instance_id)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("Failed to stop container for instance %s: %s", instance_id, e.stderr)
|
||||
raise SpawnError(f"Failed to stop container: {e.stderr}") from e
|
||||
|
||||
def get_status(self, instance_id: str) -> str:
|
||||
container_id = self._get_container_id(instance_id)
|
||||
|
||||
if not container_id:
|
||||
return "stopped"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect",
|
||||
"-f", "{{.State.Status}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
|
||||
if status == "running":
|
||||
health = self._get_health_status(container_id)
|
||||
if health == "healthy":
|
||||
return "running"
|
||||
elif health == "unhealthy":
|
||||
return "error"
|
||||
else:
|
||||
return "creating"
|
||||
elif status in ("exited", "dead"):
|
||||
return "stopped"
|
||||
elif status == "paused":
|
||||
return "stopped"
|
||||
else:
|
||||
return "creating"
|
||||
except subprocess.CalledProcessError:
|
||||
return "stopped"
|
||||
|
||||
def _get_container_id(self, instance_id: str) -> str | None:
|
||||
service_name = f"tool-{instance_id[:8]}"
|
||||
project_name = f"hq-tool-{instance_id[:8]}"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "compose",
|
||||
"-p", project_name,
|
||||
"ps", "-q", service_name,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
container_id = result.stdout.strip()
|
||||
return container_id if container_id else None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
def _get_health_status(self, container_id: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect",
|
||||
"-f", "{{.State.Health.Status}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
return status if status else None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
class TraefikLabelGenerator:
|
||||
def __init__(self, domain: str, entrypoint: str = "websecure"):
|
||||
self.domain = domain
|
||||
self.entrypoint = entrypoint
|
||||
|
||||
def generate_subdomain(
|
||||
self,
|
||||
tool_key: str,
|
||||
project_slug: str,
|
||||
user_slug: str,
|
||||
) -> str:
|
||||
return f"{tool_key}-{project_slug}-{user_slug}.{self.domain}"
|
||||
|
||||
def generate_labels(
|
||||
self,
|
||||
instance_id: str,
|
||||
tool_key: str,
|
||||
project_slug: str,
|
||||
user_slug: str,
|
||||
container_port: int,
|
||||
network_name: str = "tools",
|
||||
) -> dict[str, str]:
|
||||
subdomain = self.generate_subdomain(tool_key, project_slug, user_slug)
|
||||
router_name = f"tool-{instance_id[:8]}"
|
||||
service_name = f"tool-{instance_id[:8]}"
|
||||
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
labels["traefik.enable"] = "true"
|
||||
|
||||
labels[f"traefik.http.routers.{router_name}.rule"] = (
|
||||
f"Host(`{subdomain}`)"
|
||||
)
|
||||
labels[f"traefik.http.routers.{router_name}.entrypoints"] = (
|
||||
self.entrypoint
|
||||
)
|
||||
labels[f"traefik.http.routers.{router_name}.service"] = service_name
|
||||
|
||||
if self.entrypoint == "websecure":
|
||||
labels[f"traefik.http.routers.{router_name}.tls"] = "true"
|
||||
labels[
|
||||
f"traefik.http.routers.{router_name}.tls.certresolver"
|
||||
] = "letsencrypt"
|
||||
|
||||
labels[f"traefik.http.services.{service_name}.loadbalancer.server.port"] = (
|
||||
str(container_port)
|
||||
)
|
||||
labels[f"traefik.http.services.{service_name}.loadbalancer.server.scheme"] = (
|
||||
"http"
|
||||
)
|
||||
|
||||
middleware_name = f"tool-{instance_id[:8]}-sec"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"
|
||||
] = "31536000"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
|
||||
] = "true"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
|
||||
] = "true"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
|
||||
] = "true"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
|
||||
] = "true"
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
|
||||
] = "SAMEORIGIN"
|
||||
|
||||
labels[f"traefik.http.routers.{router_name}.middlewares"] = middleware_name
|
||||
|
||||
labels["traefik.docker.network"] = network_name
|
||||
|
||||
return labels
|
||||
|
||||
def generate_forward_auth_labels(
|
||||
self,
|
||||
instance_id: str,
|
||||
auth_url: str,
|
||||
) -> dict[str, str]:
|
||||
router_name = f"tool-{instance_id[:8]}"
|
||||
middleware_name = f"tool-{instance_id[:8]}-auth"
|
||||
|
||||
return {
|
||||
f"traefik.http.middlewares.{middleware_name}.forwardauth.address": auth_url,
|
||||
f"traefik.http.middlewares.{middleware_name}.forwardauth.trustForwardHeader": "true",
|
||||
f"traefik.http.routers.{router_name}.middlewares": middleware_name,
|
||||
}
|
||||
|
||||
def generate_removal_labels(
|
||||
self,
|
||||
instance_id: str,
|
||||
) -> dict[str, str]:
|
||||
router_name = f"tool-{instance_id[:8]}"
|
||||
|
||||
return {
|
||||
"traefik.enable": "false",
|
||||
f"traefik.http.routers.{router_name}.rule": "",
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
id: code-server
|
||||
name: code-server
|
||||
description: VS Code in the browser.
|
||||
version: "1.0.0"
|
||||
image: codercom/code-server:latest
|
||||
runtime_command:
|
||||
- "--bind-addr"
|
||||
- "0.0.0.0:8080"
|
||||
- "--auth"
|
||||
- "none"
|
||||
- "--disable-telemetry"
|
||||
- "--disable-update-check"
|
||||
runtime_entrypoint: []
|
||||
runtime_user: "coder"
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 8080
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/code-server"
|
||||
target: /home/coder/.config/code-server
|
||||
read_only: false
|
||||
env:
|
||||
PASSWORD: ""
|
||||
SUDO_PASSWORD: ""
|
||||
secrets: []
|
||||
health_check:
|
||||
type: http
|
||||
path: /healthz
|
||||
port: 8080
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 5
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 4096
|
||||
memory_swap_mb: -1
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: code
|
||||
port: 8080
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
@@ -1,42 +0,0 @@
|
||||
id: opencode
|
||||
name: OpenCode
|
||||
description: AI-powered terminal-based development environment with web interface.
|
||||
version: "1.0.0"
|
||||
image: ghcr.io/opencode-ai/opencode:latest
|
||||
runtime_working_dir: /workspace
|
||||
ports:
|
||||
- container_port: 3000
|
||||
protocol: tcp
|
||||
name: http
|
||||
primary: true
|
||||
workspace_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{project_repo}"
|
||||
target: /workspace
|
||||
read_only: false
|
||||
config_mounts:
|
||||
- type: volume
|
||||
source_pattern: "{user_config}/opencode"
|
||||
target: /root/.config/opencode
|
||||
read_only: false
|
||||
env:
|
||||
TERM: xterm-256color
|
||||
FORCE_COLOR: "1"
|
||||
health_check:
|
||||
type: http
|
||||
path: /
|
||||
port: 3000
|
||||
interval_seconds: 10
|
||||
timeout_seconds: 5
|
||||
retries: 3
|
||||
start_period_seconds: 15
|
||||
resource_limits:
|
||||
cpus: 2.0
|
||||
memory_mb: 4096
|
||||
memory_swap_mb: -1
|
||||
traefik:
|
||||
enabled: true
|
||||
subdomain_prefix: opencode
|
||||
port: 3000
|
||||
middlewares: []
|
||||
strip_prefix: false
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user