Compare commits
272 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22474cdba5 | |||
| 0c839e8c6f | |||
| c63cf7db50 | |||
| d9d2b91384 | |||
| d6ea5fb1fd | |||
| 1883825b18 | |||
| bc71fd6fac | |||
| 28aa9ccf5a | |||
| 44dd80cb58 | |||
| 23485833d8 | |||
| e23dcdf4e1 | |||
| f05ac55875 | |||
| bcefeb4163 | |||
| 33d08faf70 | |||
| 8a58c61278 | |||
| 8231e750d9 | |||
| 6ce645d210 | |||
| 89ca9f10c7 | |||
| baabd1fa62 | |||
| f14fc37e75 | |||
| e07938098a | |||
| 943b9db5c7 | |||
| a4604d6a9a | |||
| 18204628cc | |||
| 93b415c53e | |||
| e7adfb462b | |||
| ed1d6528c6 | |||
| c4be7163d6 | |||
| 13f55fff47 | |||
| 0ec20b9c23 | |||
| 4c11163bff | |||
| adda76a2ff | |||
| 47962ed476 | |||
| 6a0c9bd669 | |||
| 76fbf0a755 | |||
| 187193fa6e | |||
| cd9c9539a2 | |||
| 555517c144 | |||
| fc1554140f | |||
| bc5e80c954 | |||
| ab79080f0b | |||
| 4c216dd1ca | |||
| a37a3122f9 | |||
| a905cf729e | |||
| 3a16775188 | |||
| b363d89768 | |||
| 27c39f9cfc | |||
| e8d5b16acc | |||
| 437ad840ef | |||
| c2a232d8f0 | |||
| adaedb70ef | |||
| 5178cf9cbf | |||
| 01a0ef46c9 | |||
| a4c429d53a | |||
| 1fc244e818 | |||
| 8fb4b67372 | |||
| fbd41e3eb4 | |||
| 9c57a94e9f | |||
| 84b7b64ec0 | |||
| 29ed0f2a3b | |||
| 6c32e5266c | |||
| 9e88acaa36 | |||
| 0d10caf489 | |||
| 245d79569e | |||
| 8e86cd255c | |||
| 6ee667c384 | |||
| 21285498ae | |||
| 9751b65dce | |||
| 612217ad89 | |||
| b9ea806c0d | |||
| 22f736ce20 | |||
| deb22bec0f | |||
| 9320e175d1 | |||
| d8f220d825 | |||
| 77b7c82563 | |||
| 3fc392a314 | |||
| edd8882fa0 | |||
| 760369b102 | |||
| 8da527f964 | |||
| 76fbd74d20 | |||
| 384beeca8a | |||
| 869efda214 | |||
| 51b5d723ac | |||
| 33b482ce91 | |||
| 0cb2eefd29 | |||
| 5f5dc9c851 | |||
| 4864d269e8 | |||
| 29e4bed9e6 | |||
| c8da0ab6c4 | |||
| b04a458975 | |||
| 2299bd51ba | |||
| 45fb0c753c | |||
| c82e628e6f | |||
| 383a874bcf | |||
| 18634387c7 | |||
| 4b09f611d6 | |||
| 5c998f5bf9 | |||
| c595a513d5 | |||
| 08bd8bf7f9 | |||
| 1b7308d091 | |||
| 1060cb60ed | |||
| b58696bb7e | |||
| 4d0834069b | |||
| fc41b51bf0 | |||
| 3dbb6321fc | |||
| 7282b91d99 | |||
| 9ad11a021c | |||
| 9f29ac15da | |||
| 3092038e40 | |||
| 738e01bb7c | |||
| 4d0c70de98 | |||
| 701bd57293 | |||
| 85f04447ea | |||
| d931f3071d | |||
| 4a356fe88e | |||
| ca22e9c9d2 | |||
| 4de312c170 | |||
| e7a89a853f | |||
| 7b23618ae7 | |||
| 8b4e1a7428 | |||
| f37813a317 | |||
| 6e600fdbcd | |||
| f4211ad452 | |||
| 058c501e4a | |||
| 74033243c9 | |||
| 9910fd4445 | |||
| 4a66a4a384 | |||
| fa20d00d14 | |||
| f59274ae64 | |||
| b89fb608b6 | |||
| 116cd22ff8 | |||
| 0094ba01cd | |||
| f6fb984ec6 | |||
| 0dba13a354 | |||
| 5500552993 | |||
| fed49dba6d | |||
| fa17b13413 | |||
| 38185b9659 | |||
| 07e7c6ea0f | |||
| b638dccd36 | |||
| 8f7e19fdb1 | |||
| ba76f09a1c | |||
| 8b08c3886c | |||
| 5577e19782 | |||
| 268651bab0 | |||
| fc873e2d6b | |||
| 7389344b6d | |||
| 0a8f1419a6 | |||
| 09938bede4 | |||
| 073013bc61 | |||
| 865b9411da | |||
| 48d1a7d05c | |||
| ab1d3a6aa1 | |||
| d117047711 | |||
| d1c187ab16 | |||
| ecd3ba5918 | |||
| a919ff8611 | |||
| 39cf01c3c9 | |||
| c49bb028c4 | |||
| 0baf7f7750 | |||
| 6e496e102d | |||
| 7cb88a3163 | |||
| ce1a73abce | |||
| 5fddc65468 | |||
| f065e2b8c0 | |||
| f4cf286bb5 | |||
| 549d13f469 | |||
| 45cd192188 | |||
| 030a39dd5a | |||
| ed15d53493 | |||
| ffb7ef0d21 | |||
| e265c86997 | |||
| 23c876b558 | |||
| 74c65068c8 | |||
| 48277369f2 | |||
| 5f39267781 | |||
| caf11cdb8a | |||
| 2f44306089 | |||
| 6173d42ddf | |||
| 9afd559394 | |||
| 25da0c149a | |||
| 457f0d29ae | |||
| dfbd3e60a8 | |||
| 075eb6a76b | |||
| 4571bebf8e | |||
| ec45283257 | |||
| cce3fa773a | |||
| 50474f7b13 | |||
| 9c7043bab1 | |||
| 2dd2f2ab06 | |||
| e555561a2d | |||
| 4ac3d593aa | |||
| 1a3860a4d8 | |||
| 3d9ff44d1a | |||
| 12378def4d | |||
| f6f7853aa4 | |||
| 802d8f1e8c | |||
| 4f9aa7e3c2 | |||
| cb25b94cb7 | |||
| 01aaf4c78f | |||
| 96c8dd7402 | |||
| f5c2c95af0 | |||
| 06fe8623bc | |||
| f2fed518f0 | |||
| cc2a638c76 | |||
| 5cec4a7a6f | |||
| 7e0df57f8c | |||
| aea1ff95f6 | |||
| c8fdca7f60 | |||
| 014b88ee56 | |||
| b6bda3d692 | |||
| d7fb51f427 | |||
| 0d57e3501a | |||
| 3672312028 | |||
| c5f117e5b1 | |||
| 10a5c29702 | |||
| 312a646b89 | |||
| 01adc9a00f | |||
| 2e9ca52cdb | |||
| cd4eba9803 | |||
| 18646e3d1b | |||
| 8c5e1b931e | |||
| d1be2e4951 | |||
| 953ea05756 | |||
| 507b71c586 | |||
| aebcf25bf4 | |||
| ae42cac61e | |||
| e2ad7d7fb6 | |||
| 9e1334eb6d | |||
| c41993310b | |||
| cb25f21c44 | |||
| 392e85ead4 | |||
| a4bf8afac9 | |||
| a559470369 | |||
| 8cab17472e | |||
| e9d404b1ff | |||
| dab6c74046 | |||
| ca8b255148 | |||
| 02a2ad6df5 | |||
| 4ef0f108ea | |||
| dc8ef0e463 | |||
| 6a7657aeda | |||
| eca8b8815b | |||
| d0f7a97f92 | |||
| 765cb965e6 | |||
| 063a839790 | |||
| ae41a64e66 | |||
| 0901b1e832 | |||
| 7cc720786e | |||
| e167a6be12 | |||
| 0fa926284c | |||
| 5c17de0c3c | |||
| 8efadc4432 | |||
| 1e40540ef4 | |||
| 7cbbb41661 | |||
| 952a9f3234 | |||
| ab8872f79e | |||
| be4893e2a7 | |||
| b3c6a5fdc9 | |||
| b7d17cea78 | |||
| 36d6448f5f | |||
| 20a5f6a9a1 | |||
| 1c94583307 | |||
| 95a7454bee | |||
| 649496b762 | |||
| d13e16f5e1 | |||
| d5f9df33b7 | |||
| 468e0eacda | |||
| 2a9e57ad0d | |||
| e4c5e7f2db | |||
| 70957e462a | |||
| 684a11610a |
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
name: sift-backlog
|
||||||
|
description: Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sift Backlog
|
||||||
|
|
||||||
|
Triage backlog tasks: prioritize, group into plans, set dependencies, and activate.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
1. List backlog tasks (`sf task backlog`)
|
||||||
|
2. Clarify and enrich each task (titles, descriptions)
|
||||||
|
3. Identify groupings and create draft plans
|
||||||
|
4. Add tasks to plans and set dependencies
|
||||||
|
5. Activate plans
|
||||||
|
6. Set task status to open
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: List Backlog Tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task backlog
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Clarify and Enrich Tasks
|
||||||
|
|
||||||
|
Backlog tasks often have only a brief title with no description. Before organizing, ensure each task is well-defined.
|
||||||
|
|
||||||
|
**For each task, evaluate:**
|
||||||
|
|
||||||
|
- Is the title clear and actionable?
|
||||||
|
- Is there a description? Check with `sf task describe <task-id> --show`
|
||||||
|
- Is the scope unambiguous?
|
||||||
|
|
||||||
|
**If the title is unclear**, update it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --title "Clear, actionable title"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add a description** with context, scope, and acceptance criteria:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task describe <task-id> --content "Description with:
|
||||||
|
- What needs to be done
|
||||||
|
- Why it matters
|
||||||
|
- Acceptance criteria
|
||||||
|
- Any relevant context"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use your best judgment** to interpret tasks and make reasonable decisions about scope, grouping, and priority. You have context about the codebase, project patterns, and typical development practices—leverage this knowledge rather than deferring to the user for routine decisions.
|
||||||
|
|
||||||
|
**Only ask the user for clarity when absolutely necessary:**
|
||||||
|
|
||||||
|
- The task is fundamentally ambiguous (multiple mutually exclusive interpretations)
|
||||||
|
- Critical business logic or user-facing behavior that could go wrong in meaningful ways
|
||||||
|
- External dependencies or integrations you cannot verify
|
||||||
|
|
||||||
|
**Do NOT ask about:**
|
||||||
|
|
||||||
|
- Implementation details you can reasonably infer
|
||||||
|
- Priority or grouping decisions—use your judgment
|
||||||
|
- Standard development practices (testing, code style, etc.)
|
||||||
|
- Tasks where a reasonable interpretation exists
|
||||||
|
|
||||||
|
### Step 3: Create Draft Plans
|
||||||
|
|
||||||
|
Group related tasks into plans using your best judgment. Plans start as drafts (tasks won't be dispatched until activated).
|
||||||
|
|
||||||
|
**Grouping guidance:**
|
||||||
|
|
||||||
|
- Group tasks that share a common theme, feature area, or goal
|
||||||
|
- Consider technical dependencies when grouping (tasks that touch the same files/modules)
|
||||||
|
- Separate unrelated work into distinct plans for parallel execution
|
||||||
|
- Don't over-group—if tasks are truly independent, separate plans enable better parallelism
|
||||||
|
- Don't under-group—related tasks benefit from shared context and coordinated execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Plan Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan create --title "Authentication Improvements"
|
||||||
|
# Output: Created plan el-abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Add Tasks to Plans
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task <plan-id> <task-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan add-task el-abc123 el-task1
|
||||||
|
sf plan add-task el-abc123 el-task2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Set Dependencies Between Tasks
|
||||||
|
|
||||||
|
Use `blocks` dependency when one task must complete before another can start.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add <blocked-id> <blocker-id> --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semantics:** The first ID is blocked BY the second ID. The blocker must complete first.
|
||||||
|
|
||||||
|
**Example:** Task 2 can't start until Task 1 completes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf dependency add el-task2 el-task1 --type blocks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update Priorities
|
||||||
|
|
||||||
|
Set priorities based on your assessment of impact, urgency, and dependencies. Use your judgment—you don't need user confirmation for routine prioritization.
|
||||||
|
|
||||||
|
**Priority guidance:**
|
||||||
|
|
||||||
|
- **Critical (1):** Blocking issues, security vulnerabilities, production bugs
|
||||||
|
- **High (2):** Important features with deadlines, significant user impact
|
||||||
|
- **Medium (3):** Standard feature work, most tasks default here
|
||||||
|
- **Low (4):** Nice-to-haves, minor improvements, tech debt
|
||||||
|
- **Minimal (5):** Backlog cleanup, documentation, exploratory work
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <task-id> --priority <1-5>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Value | Level |
|
||||||
|
| ----- | -------- |
|
||||||
|
| 1 | Critical |
|
||||||
|
| 2 | High |
|
||||||
|
| 3 | Medium |
|
||||||
|
| 4 | Low |
|
||||||
|
| 5 | Minimal |
|
||||||
|
|
||||||
|
### Step 7: Activate Plans
|
||||||
|
|
||||||
|
Once tasks are organized with dependencies set, activate plans to enable dispatch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan activate <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Set Task Status to Open
|
||||||
|
|
||||||
|
Move tasks from backlog to open so they become ready for work.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf update <id> --status open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Actions
|
||||||
|
|
||||||
|
**Close obsolete tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task close <id> --reason "Won't do: <reason>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defer tasks:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf task defer <id> --until <date>
|
||||||
|
```
|
||||||
|
|
||||||
|
**View existing plans:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan list
|
||||||
|
```
|
||||||
|
|
||||||
|
**View tasks in a plan:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sf plan tasks <plan-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Use your best judgment** for grouping, prioritization, and task interpretation—don't defer routine decisions to the user
|
||||||
|
- **Only escalate to the user** when ambiguity is fundamental and could lead to wasted work (mutually exclusive interpretations, critical business decisions)
|
||||||
|
- Make reasonable inferences about implementation details, scope, and priority based on codebase context
|
||||||
|
- Create plans before setting dependencies to avoid dispatch race conditions
|
||||||
|
- Always activate plans after dependencies are set
|
||||||
|
- Focus on oldest backlog items first (sorted by creation date)
|
||||||
|
- Every task should have a clear title and description before activation
|
||||||
|
- When uncertain about a minor detail, make a reasonable choice and document it in the task description—workers can ask if needed
|
||||||
+1
-5
@@ -48,8 +48,4 @@ apps/web/dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/.stoneforge/.worktrees/
|
||||||
# Local runtime state
|
|
||||||
.atl/
|
|
||||||
.pi/
|
|
||||||
swap-pane
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
262629
|
||||||
|
1779624255076
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime data
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
daemon-state.json
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Stoneforge Configuration
|
||||||
|
|
||||||
|
database: stoneforge.db
|
||||||
|
sync:
|
||||||
|
auto_export: true
|
||||||
|
elements_file: elements.jsonl
|
||||||
|
dependencies_file: dependencies.jsonl
|
||||||
|
playbooks:
|
||||||
|
paths:
|
||||||
|
- playbooks
|
||||||
|
identity:
|
||||||
|
mode: soft
|
||||||
|
merge:
|
||||||
|
auto_merge: true
|
||||||
|
target_branch: null
|
||||||
|
require_approval: false
|
||||||
|
workflow:
|
||||||
|
preset: auto
|
||||||
|
agents:
|
||||||
|
permission_model: unrestricted
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{"blockedId":"el-1of","blockerId":"el-258","type":"parent-child","createdAt":"2026-05-24T09:44:58.759Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5fe","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:40.892Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.010Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.127Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.244Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.372Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.490Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.607Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.726Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.844Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:41.959Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.074Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.195Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.311Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.425Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-20no","type":"parent-child","createdAt":"2026-05-24T12:44:42.541Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1nj","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.651Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1bn","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4hr","blockerId":"el-5fe","type":"blocks","createdAt":"2026-05-24T12:44:42.868Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:42.979Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-62c","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.092Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.205Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5z8","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.313Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:43.422Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-4hr","type":"blocks","createdAt":"2026-05-24T12:44:43.529Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1t7","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:43.647Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-5j5","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.758Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-2xl","blockerId":"el-1t7","type":"blocks","createdAt":"2026-05-24T12:44:43.876Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1nj","type":"blocks","createdAt":"2026-05-24T12:44:43.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-1bn","type":"blocks","createdAt":"2026-05-24T12:44:44.096Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-4bc","blockerId":"el-62c","type":"blocks","createdAt":"2026-05-24T12:44:44.208Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-107","blockerId":"el-5z8","type":"blocks","createdAt":"2026-05-24T12:44:44.319Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-5j5","type":"blocks","createdAt":"2026-05-24T12:44:44.429Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-32e","blockerId":"el-2xl","type":"blocks","createdAt":"2026-05-24T12:44:44.539Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-4bc","type":"blocks","createdAt":"2026-05-24T12:44:44.650Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-3ou","blockerId":"el-107","type":"blocks","createdAt":"2026-05-24T12:44:44.761Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-14w","blockerId":"el-32e","type":"blocks","createdAt":"2026-05-24T12:44:44.873Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-3ou","type":"blocks","createdAt":"2026-05-24T12:44:44.987Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-1ou","blockerId":"el-14w","type":"blocks","createdAt":"2026-05-24T12:44:45.107Z","createdBy":"el-2jua"}
|
||||||
|
{"blockedId":"el-375","blockerId":"el-26p","type":"replies-to","createdAt":"2026-05-24T13:21:42.486Z","createdBy":"el-2i1s"}
|
||||||
|
{"blockedId":"el-3n4","blockerId":"el-31p","type":"replies-to","createdAt":"2026-05-24T13:21:46.044Z","createdBy":"el-13ju"}
|
||||||
|
{"blockedId":"el-3jer","blockerId":"el-1xx","type":"replies-to","createdAt":"2026-05-24T13:24:47.580Z","createdBy":"el-4350"}
|
||||||
|
{"blockedId":"el-1afv","blockerId":"el-1ozw","type":"replies-to","createdAt":"2026-05-24T13:32:42.658Z","createdBy":"el-51a8"}
|
||||||
File diff suppressed because one or more lines are too long
@@ -87,6 +87,31 @@ Do not claim completion without verification evidence.
|
|||||||
|
|
||||||
## Git workflow
|
## 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
|
### Auto-commit on spec completion
|
||||||
|
|
||||||
When an OpenSpec change is fully implemented and all tasks are complete:
|
When an OpenSpec change is fully implemented and all tasks are complete:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **User Settings** - Theme selection, git identity, and preference management
|
- **User Settings** - Theme selection, git identity, and preference management
|
||||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
|
||||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
git \
|
git \
|
||||||
|
openssh-client \
|
||||||
netcat-openbsd \
|
netcat-openbsd \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
"""add config profiles, includes, mounts, and tool instance profile selection
|
|
||||||
|
|
||||||
Revision ID: 0013_add_config_profiles
|
|
||||||
Revises: 0012_default_port_req
|
|
||||||
Create Date: 2026-05-24 12: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 = "0013_add_config_profiles"
|
|
||||||
down_revision: Union[str, None] = "0012_default_port_req"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create config_profiles table
|
|
||||||
op.create_table(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), 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"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
|
||||||
)
|
|
||||||
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
|
||||||
|
|
||||||
# Create config_includes table
|
|
||||||
op.create_table(
|
|
||||||
"config_includes",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
|
||||||
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(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
|
||||||
)
|
|
||||||
op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"])
|
|
||||||
op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"])
|
|
||||||
|
|
||||||
# Create config_mounts table
|
|
||||||
op.create_table(
|
|
||||||
"config_mounts",
|
|
||||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column("mount_path", sa.String(length=1024), nullable=False),
|
|
||||||
sa.Column("content", sa.Text(), nullable=True),
|
|
||||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
|
||||||
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(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"),
|
|
||||||
sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"])
|
|
||||||
|
|
||||||
# Add selected_profile_id to tool_instances
|
|
||||||
op.add_column(
|
|
||||||
"tool_instances",
|
|
||||||
sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
"fk_tool_instances_selected_profile",
|
|
||||||
"tool_instances",
|
|
||||||
"config_profiles",
|
|
||||||
["selected_profile_id"],
|
|
||||||
["id"],
|
|
||||||
ondelete="SET NULL",
|
|
||||||
)
|
|
||||||
op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Remove selected_profile_id from tool_instances
|
|
||||||
op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances")
|
|
||||||
op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey")
|
|
||||||
op.drop_column("tool_instances", "selected_profile_id")
|
|
||||||
|
|
||||||
# Drop config_mounts
|
|
||||||
op.drop_index("idx_config_mounts_profile", table_name="config_mounts")
|
|
||||||
op.drop_table("config_mounts")
|
|
||||||
|
|
||||||
# Drop config_includes
|
|
||||||
op.drop_index("idx_config_includes_included", table_name="config_includes")
|
|
||||||
op.drop_index("idx_config_includes_profile", table_name="config_includes")
|
|
||||||
op.drop_table("config_includes")
|
|
||||||
|
|
||||||
# Drop config_profiles
|
|
||||||
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
|
||||||
op.drop_table("config_profiles")
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add probe_result to tool_instances
|
||||||
|
|
||||||
|
Revision ID: 0013_add_probe_result
|
||||||
|
Revises: 0012_default_port_req
|
||||||
|
Create Date: 2026-05-22 21:45: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 = "0013_add_probe_result"
|
||||||
|
down_revision: Union[str, None] = "0012_default_port_req"
|
||||||
|
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("probe_result", postgresql.JSON, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "probe_result")
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"""add profile resolver fields to config profiles and mounts
|
|
||||||
|
|
||||||
Revision ID: 0014_add_profile_resolver_fields
|
|
||||||
Revises: 0013_add_config_profiles
|
|
||||||
Create Date: 2026-05-24 14: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 = "0014_add_profile_resolver_fields"
|
|
||||||
down_revision: Union[str, None] = "0013_add_config_profiles"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Add fields to config_profiles
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("environment_variables", sa.JSON(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("start_command", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("working_directory", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("port", sa.Integer(), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_profiles",
|
|
||||||
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add foreign keys for project and tool_type
|
|
||||||
op.create_foreign_key(
|
|
||||||
"fk_config_profiles_project",
|
|
||||||
"config_profiles",
|
|
||||||
"projects",
|
|
||||||
["project_id"],
|
|
||||||
["id"],
|
|
||||||
ondelete="CASCADE",
|
|
||||||
)
|
|
||||||
op.create_foreign_key(
|
|
||||||
"fk_config_profiles_tool_type",
|
|
||||||
"config_profiles",
|
|
||||||
"tool_types",
|
|
||||||
["tool_type_id"],
|
|
||||||
["id"],
|
|
||||||
ondelete="CASCADE",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indices
|
|
||||||
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
|
||||||
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
|
||||||
|
|
||||||
# Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON
|
|
||||||
op.alter_column("config_mounts", "mount_path", new_column_name="target_path")
|
|
||||||
op.add_column(
|
|
||||||
"config_mounts",
|
|
||||||
sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_mounts",
|
|
||||||
sa.Column("files", sa.JSON(), nullable=True),
|
|
||||||
)
|
|
||||||
# Drop the source_profile foreign key if it exists
|
|
||||||
op.drop_constraint(
|
|
||||||
"config_mounts_source_profile_id_fkey",
|
|
||||||
"config_mounts",
|
|
||||||
type_="foreignkey",
|
|
||||||
)
|
|
||||||
op.drop_column("config_mounts", "content")
|
|
||||||
op.drop_column("config_mounts", "source_profile_id")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Restore config_mounts
|
|
||||||
op.add_column(
|
|
||||||
"config_mounts",
|
|
||||||
sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"config_mounts",
|
|
||||||
sa.Column("content", sa.Text(), nullable=True),
|
|
||||||
)
|
|
||||||
op.drop_column("config_mounts", "files")
|
|
||||||
op.drop_column("config_mounts", "mode")
|
|
||||||
op.alter_column("config_mounts", "target_path", new_column_name="mount_path")
|
|
||||||
|
|
||||||
# Restore config_profiles
|
|
||||||
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
|
||||||
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
|
||||||
op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey")
|
|
||||||
op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey")
|
|
||||||
op.drop_column("config_profiles", "is_default")
|
|
||||||
op.drop_column("config_profiles", "port")
|
|
||||||
op.drop_column("config_profiles", "working_directory")
|
|
||||||
op.drop_column("config_profiles", "start_command")
|
|
||||||
op.drop_column("config_profiles", "environment_variables")
|
|
||||||
op.drop_column("config_profiles", "tool_type_id")
|
|
||||||
op.drop_column("config_profiles", "project_id")
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge migration heads
|
||||||
|
|
||||||
|
Revision ID: 0014_merge_heads
|
||||||
|
Revises: 0013_add_probe_result, 8ed7dd80973d
|
||||||
|
Create Date: 2026-05-22 21:50:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0014_merge_heads"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0013_add_probe_result", "8ed7dd80973d")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""replace interfaces with interface_type and add requires_port
|
||||||
|
|
||||||
|
Revision ID: 0015_single_interface
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 22: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 = "0015_single_interface"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "0014_merge_heads"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dialect() -> str:
|
||||||
|
"""Get the current database dialect name."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
return conn.dialect.name
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Add new columns
|
||||||
|
op.add_column('tool_types', sa.Column('interface_type', sa.String(20), nullable=True))
|
||||||
|
op.add_column('tool_types', sa.Column('requires_port', sa.Boolean(), nullable=False, server_default='true'))
|
||||||
|
|
||||||
|
# Migrate data: take first element from interfaces JSON array
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT elem FROM jsonb_array_elements_text(interfaces::jsonb) AS elem LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# SQLite: interfaces is stored as JSON text, extract first array element
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interface_type = COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
),
|
||||||
|
requires_port = CASE
|
||||||
|
WHEN COALESCE(
|
||||||
|
(SELECT json_extract(value, '$[0]')
|
||||||
|
FROM json_each(interfaces) AS value
|
||||||
|
WHERE json_valid(interfaces)
|
||||||
|
LIMIT 1),
|
||||||
|
'web'
|
||||||
|
) = 'web' THEN true
|
||||||
|
ELSE false
|
||||||
|
END
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Make interface_type non-nullable after data migration
|
||||||
|
op.alter_column('tool_types', 'interface_type', nullable=False)
|
||||||
|
|
||||||
|
# Drop old interfaces column
|
||||||
|
op.drop_column('tool_types', 'interfaces')
|
||||||
|
|
||||||
|
# Add CHECK constraint for interface_type (only on PostgreSQL; SQLite supports it too)
|
||||||
|
op.create_check_constraint('chk_interface_type', 'tool_types', sa.text("interface_type IN ('web', 'terminal')"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
dialect = _get_dialect()
|
||||||
|
|
||||||
|
# Drop CHECK constraint
|
||||||
|
op.drop_constraint('chk_interface_type', 'tool_types', type_='check')
|
||||||
|
|
||||||
|
# Add back interfaces column
|
||||||
|
if dialect == 'postgresql':
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = jsonb_build_array(interface_type)
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.add_column('tool_types', sa.Column('interfaces', sa.JSON(), nullable=False, server_default='["web"]'))
|
||||||
|
|
||||||
|
# Migrate data back: wrap interface_type in array for SQLite
|
||||||
|
op.execute("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET interfaces = json_array(interface_type)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Drop new columns
|
||||||
|
op.drop_column('tool_types', 'requires_port')
|
||||||
|
op.drop_column('tool_types', 'interface_type')
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""add pi agent tool type
|
||||||
|
|
||||||
|
Revision ID: 20260527_160017_add_pi_agent
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-27T16:00:17
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260527_160017_add_pi_agent"
|
||||||
|
down_revision: Union[str, None] = "2026_05_27_external_repos"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
PI_AGENT_ID = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Check if pi-agent already exists
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT id FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO tool_types (
|
||||||
|
id, name, display_name, description, category,
|
||||||
|
interface_type, requires_port, default_port,
|
||||||
|
definition_type, compose_template, dockerfile_template, required_variables,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :name, :display_name, :description, :category,
|
||||||
|
:interface_type, :requires_port, :default_port,
|
||||||
|
:definition_type, :compose_template, :dockerfile_template, :required_variables,
|
||||||
|
now(), now()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": PI_AGENT_ID,
|
||||||
|
"name": "pi-agent",
|
||||||
|
"display_name": "Pi Agent",
|
||||||
|
"description": "Pi coding agent terminal environment with nvim, ranger, and tmux",
|
||||||
|
"category": "development",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "dockerfile",
|
||||||
|
"compose_template": """services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/workspace
|
||||||
|
working_dir: /workspace
|
||||||
|
command: /bin/bash""",
|
||||||
|
"dockerfile_template": """# Pi Coding Agent - Terminal-based coding harness
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \\
|
||||||
|
curl \\
|
||||||
|
wget \\
|
||||||
|
git \\
|
||||||
|
neovim \\
|
||||||
|
ranger \\
|
||||||
|
tmux \\
|
||||||
|
htop \\
|
||||||
|
tree \\
|
||||||
|
jq \\
|
||||||
|
ca-certificates \\
|
||||||
|
python3 \\
|
||||||
|
python3-pip \\
|
||||||
|
build-essential \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js (required for Pi)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\
|
||||||
|
&& apt-get install -y nodejs \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Pi Coding Agent globally
|
||||||
|
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -s /bin/bash user
|
||||||
|
WORKDIR /home/user
|
||||||
|
|
||||||
|
# Set up git
|
||||||
|
RUN git config --global init.defaultBranch main \\
|
||||||
|
&& git config --global user.email "dev@headquarter.local" \\
|
||||||
|
&& git config --global user.name "Developer"
|
||||||
|
|
||||||
|
# Create default tmux config
|
||||||
|
RUN echo 'set -g mouse on\\nset -g default-terminal "screen-256color"' > /home/user/.tmux.conf
|
||||||
|
|
||||||
|
# Create default ranger config
|
||||||
|
RUN mkdir -p /home/user/.config/ranger \\
|
||||||
|
&& echo 'set preview_files true\\nset use_preview_script true' > /home/user/.config/ranger/rc.conf
|
||||||
|
|
||||||
|
# Set up Pi config directory
|
||||||
|
RUN mkdir -p /home/user/.pi/agent
|
||||||
|
|
||||||
|
USER user
|
||||||
|
|
||||||
|
# Default to bash (Pi is invoked manually via `pi` command)
|
||||||
|
CMD ["/bin/bash"]""",
|
||||||
|
"required_variables": json.dumps(["REPO_PATH"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(
|
||||||
|
sa.text("DELETE FROM tool_types WHERE name = 'pi-agent'")
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""add_clone_mode_and_ssh_key_id
|
||||||
|
|
||||||
|
Revision ID: 2026_05_22_add_clone_mode
|
||||||
|
Revises: 0014_merge_heads
|
||||||
|
Create Date: 2026-05-22 20:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_22_add_clone_mode'
|
||||||
|
down_revision = '0015_single_interface'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add ssh_key_id to git_repositories
|
||||||
|
op.add_column('git_repositories', sa.Column('ssh_key_id', postgresql.UUID(), nullable=True))
|
||||||
|
op.create_foreign_key('fk_git_repositories_ssh_key', 'git_repositories', 'ssh_keys', ['ssh_key_id'], ['id'])
|
||||||
|
|
||||||
|
# Add clone_mode and branch to tool_instances
|
||||||
|
op.add_column('tool_instances', sa.Column('clone_mode', sa.String(20), nullable=False, server_default='mount'))
|
||||||
|
op.add_column('tool_instances', sa.Column('branch', sa.String(255), nullable=True, server_default='main'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop columns from tool_instances
|
||||||
|
op.drop_column('tool_instances', 'branch')
|
||||||
|
op.drop_column('tool_instances', 'clone_mode')
|
||||||
|
|
||||||
|
# Drop ssh_key_id from git_repositories
|
||||||
|
op.drop_constraint('fk_git_repositories_ssh_key', 'git_repositories', type_='foreignkey')
|
||||||
|
op.drop_column('git_repositories', 'ssh_key_id')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""remove_is_builtin_from_tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_23_remove_is_builtin
|
||||||
|
Revises: 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-23 14:30:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2026_05_23_remove_is_builtin'
|
||||||
|
down_revision = 'f3d2dc90ba3a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Drop the is_builtin column from tool_types
|
||||||
|
op.execute("ALTER TABLE tool_types DROP COLUMN IF EXISTS is_builtin")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Add the is_builtin column back to tool_types
|
||||||
|
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""add startup_command to tool_types
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_220141
|
||||||
|
Revises: 6fc7bfcf199f
|
||||||
|
Create Date: 2026-05-24 22:01:41.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_24_220141"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "6fc7bfcf199f"
|
||||||
|
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("startup_command", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_types", "startup_command")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_24_add_config_profiles
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-24 14: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 = "2026_05_24_add_config_profiles"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f3d2dc90ba3a"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create config_profiles table
|
||||||
|
op.create_table(
|
||||||
|
"config_profiles",
|
||||||
|
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), 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("project_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("env_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("runtime_hints", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("mounts", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="[]"),
|
||||||
|
sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profiles
|
||||||
|
op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"])
|
||||||
|
op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"])
|
||||||
|
op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"])
|
||||||
|
|
||||||
|
# Create config_profile_includes table
|
||||||
|
op.create_table(
|
||||||
|
"config_profile_includes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
|
||||||
|
sa.Column("profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
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.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_profile_includes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for config_profile_includes
|
||||||
|
op.create_index("idx_config_profile_includes_profile", "config_profile_includes", ["profile_id"])
|
||||||
|
op.create_index("idx_config_profile_includes_included", "config_profile_includes", ["included_profile_id"])
|
||||||
|
|
||||||
|
# Add selected_config_profile_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("selected_config_profile_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_tool_instances_config_profile", "tool_instances", ["selected_config_profile_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove selected_config_profile_id from tool_instances
|
||||||
|
op.drop_index("idx_tool_instances_config_profile", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "selected_config_profile_id")
|
||||||
|
|
||||||
|
# Drop config_profile_includes table
|
||||||
|
op.drop_index("idx_config_profile_includes_included", table_name="config_profile_includes")
|
||||||
|
op.drop_index("idx_config_profile_includes_profile", table_name="config_profile_includes")
|
||||||
|
op.drop_table("config_profile_includes")
|
||||||
|
|
||||||
|
# Drop config_profiles table
|
||||||
|
op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_project", table_name="config_profiles")
|
||||||
|
op.drop_index("idx_config_profiles_user", table_name="config_profiles")
|
||||||
|
op.drop_table("config_profiles")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add_git_mounts_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_26_add_git_mounts
|
||||||
|
Revises: f3d2dc90ba3a
|
||||||
|
Create Date: 2026-05-26 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_26_add_git_mounts"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_24_220141"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column("git_mounts", sa.JSON(), nullable=True, default=list),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "git_mounts")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""make_project_id_nullable_in_git_repositories
|
||||||
|
|
||||||
|
Revision ID: 2026_05_27_external_repos
|
||||||
|
Revises: 2026_05_26_add_git_mounts
|
||||||
|
Create Date: 2026-05-27 08:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_27_external_repos"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "2026_05_26_add_git_mounts"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Expand alembic_version version_num to avoid truncation errors
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(64)")
|
||||||
|
|
||||||
|
# Make project_id nullable to allow external repositories
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"git_repositories",
|
||||||
|
"project_id",
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(32)")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge_remove_is_builtin_and_add_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 6fc7bfcf199f
|
||||||
|
Revises: 2026_05_23_remove_is_builtin, 2026_05_24_add_config_profiles
|
||||||
|
Create Date: 2026-05-24 18:00:43.990361
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6fc7bfcf199f'
|
||||||
|
down_revision = ('2026_05_23_remove_is_builtin', '2026_05_24_add_config_profiles')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""merge_single_interface_and_clone_mode
|
||||||
|
|
||||||
|
Revision ID: f3d2dc90ba3a
|
||||||
|
Revises: 0015_single_interface, 2026_05_22_add_clone_mode
|
||||||
|
Create Date: 2026-05-24 10:43:14.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f3d2dc90ba3a"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ("0015_single_interface", "2026_05_22_add_clone_mode")
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+10
-10
@@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse:
|
|||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
state=state,
|
state=state,
|
||||||
)
|
)
|
||||||
logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next)
|
||||||
response = RedirectResponse(location)
|
response = RedirectResponse(location)
|
||||||
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
response.set_cookie("auth_state", state, httponly=True, samesite="lax")
|
||||||
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
response.set_cookie("auth_next", next, httponly=True, samesite="lax")
|
||||||
@@ -63,7 +63,7 @@ async def callback(
|
|||||||
auth_next: str | None = Cookie(default="/"),
|
auth_next: str | None = Cookie(default="/"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None")
|
||||||
|
|
||||||
if auth_state is None or auth_state != state:
|
if auth_state is None or auth_state != state:
|
||||||
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state)
|
||||||
@@ -71,7 +71,7 @@ async def callback(
|
|||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
@@ -92,7 +92,7 @@ async def callback(
|
|||||||
access_token=token_payload["access_token"],
|
access_token=token_payload["access_token"],
|
||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
logger.info("User info fetched successfully")
|
logger.debug("User info fetched successfully")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("User info fetch failed: %s", exc)
|
logger.error("User info fetch failed: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||||
@@ -100,19 +100,19 @@ async def callback(
|
|||||||
authentik_id = str(user_info.get("sub", ""))
|
authentik_id = str(user_info.get("sub", ""))
|
||||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||||
name = str(user_info.get("name", email))
|
name = str(user_info.get("name", email))
|
||||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
user = await session.scalar(select(User).where(User.authentik_id == authentik_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
logger.info("Creating new user: authentik_id=%s", authentik_id)
|
logger.debug("Creating new user: authentik_id=%s", authentik_id)
|
||||||
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
logger.info("New user created: id=%s", user.id)
|
logger.info("New user created: id=%s", user.id)
|
||||||
else:
|
else:
|
||||||
logger.info("Existing user found: id=%s, updating info", user.id)
|
logger.debug("Existing user found: id=%s, updating info", user.id)
|
||||||
user.email = email
|
user.email = email
|
||||||
user.name = name
|
user.name = name
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -165,20 +165,20 @@ async def me(
|
|||||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info("Auth /me called, cookie present: %s", bool(session_cookie))
|
logger.debug("Auth /me called, cookie present: %s", bool(session_cookie))
|
||||||
|
|
||||||
if not session_cookie:
|
if not session_cookie:
|
||||||
logger.warning("Auth /me: missing session cookie")
|
logger.warning("Auth /me: missing session cookie")
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s",
|
||||||
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
user_id = payload["user_id"]
|
user_id = payload["user_id"]
|
||||||
logger.info("Auth /me: decoded session for user_id=%s", user_id)
|
logger.debug("Auth /me: decoded session for user_id=%s", user_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("Auth /me: invalid session: %s", exc)
|
logger.warning("Auth /me: invalid session: %s", exc)
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||||
|
|||||||
@@ -4,24 +4,77 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
from src.schemas.config_folder import (
|
|
||||||
ConfigFolderCreate,
|
|
||||||
ConfigFolderUpdate,
|
|
||||||
ConfigFolderResponse,
|
|
||||||
ProjectOverrideCreate,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderCreate(BaseModel):
|
||||||
|
name: str = Field(description="Folder name (unique per user)")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str = Field(description="Default mount path in container")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {path: content}")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str) -> str:
|
||||||
|
return _validate_mount_path(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
return _validate_files(v)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, description="Folder name")
|
||||||
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
|
mount_path: str | None = Field(default=None, description="Default mount path")
|
||||||
|
files: dict | None = Field(default=None, description="Files as {path: content}")
|
||||||
|
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_mount_path(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
return _validate_files(v)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOverrideCreate(BaseModel):
|
||||||
|
mount_path: str | None = Field(default=None, description="Override mount path")
|
||||||
|
files: dict = Field(default_factory=dict, description="Override files")
|
||||||
|
|
||||||
|
@field_validator("mount_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_mount_path(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_mount_path(v)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigFolderResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
mount_path: str
|
||||||
|
files: dict
|
||||||
|
project_overrides: dict | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
|
||||||
async def list_config_folders(
|
async def list_config_folders(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
|||||||
+662
-238
@@ -3,297 +3,721 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.config_include import ConfigInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.config_mount import ConfigMount
|
from src.models.project import Project
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user_config import UserConfig
|
from src.services.config_profile_resolver import (
|
||||||
from src.schemas.config_profile import (
|
ConfigProfileCycleError,
|
||||||
ConfigIncludeCreate,
|
check_include_cycle,
|
||||||
ConfigIncludeUpdate,
|
resolve_profile,
|
||||||
ConfigMountCreate,
|
resolved_profile_to_dict,
|
||||||
ConfigMountUpdate,
|
|
||||||
ConfigProfileCreate,
|
|
||||||
ConfigProfileUpdate,
|
|
||||||
DefaultProfilesUpdate,
|
|
||||||
)
|
|
||||||
from src.services.config_profiles import (
|
|
||||||
check_duplicate_include,
|
|
||||||
check_duplicate_mount_path,
|
|
||||||
check_duplicate_name,
|
|
||||||
get_default_profile_for_tool_type,
|
|
||||||
get_default_profiles,
|
|
||||||
get_owned_profile,
|
|
||||||
include_to_dict,
|
|
||||||
list_includes_for_profile,
|
|
||||||
list_mounts_for_profile,
|
|
||||||
mount_to_dict,
|
|
||||||
profile_to_dict,
|
|
||||||
set_default_profiles,
|
|
||||||
validate_includes_no_cycle,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||||
|
|
||||||
|
MAX_PROFILE_SIZE_MB = 10
|
||||||
|
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
def _validate_uuid(v: str | None) -> str | None:
|
||||||
async def list_config_profiles(
|
if v is None:
|
||||||
tool_type_id: str | None = None,
|
return v
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
try:
|
||||||
session: AsyncSession = Depends(get_db_session),
|
uuid.UUID(v)
|
||||||
) -> dict:
|
except ValueError:
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
raise ValueError(f"Invalid UUID: {v}")
|
||||||
if tool_type_id:
|
return v
|
||||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
||||||
if tool_type is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
||||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
|
||||||
return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
def _calculate_profile_size(data: dict) -> int:
|
||||||
async def create_config_profile(
|
"""Calculate approximate serialized size of profile data."""
|
||||||
data: ConfigProfileCreate,
|
total = 0
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
for key, value in data.get("env_vars", {}).items():
|
||||||
session: AsyncSession = Depends(get_db_session),
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
) -> dict:
|
for key, value in data.get("runtime_hints", {}).items():
|
||||||
await check_duplicate_name(session, user_id, data.name)
|
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
|
||||||
profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description)
|
for mount in data.get("mounts", []):
|
||||||
session.add(profile)
|
total += len(str(mount.get("target", "")).encode("utf-8"))
|
||||||
await session.commit()
|
total += len(str(mount.get("mode", "")).encode("utf-8"))
|
||||||
await session.refresh(profile)
|
for path, content in mount.get("files", {}).items():
|
||||||
return profile_to_dict(profile)
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
for path, content in data.get("files", {}).items():
|
||||||
|
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults")
|
class GitMountItem(BaseModel):
|
||||||
async def get_default_profiles_endpoint(
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
||||||
session: AsyncSession = Depends(get_db_session),
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
) -> dict:
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
return await get_default_profiles(session, user_id)
|
|
||||||
|
@field_validator("remote_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_path(cls, v: str) -> str:
|
||||||
|
if v.startswith("/"):
|
||||||
|
raise ValueError("source_path must be relative (no leading /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("source_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str) -> str:
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
@router.put("/defaults")
|
class MountItem(BaseModel):
|
||||||
async def set_default_profiles_endpoint(
|
target: str = Field(description="Absolute mount target path")
|
||||||
data: DefaultProfilesUpdate,
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
@field_validator("target")
|
||||||
return await set_default_profiles(session, user_id, data.default_profiles)
|
@classmethod
|
||||||
|
def validate_target(cls, v: str) -> str:
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount target must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mode")
|
||||||
|
@classmethod
|
||||||
|
def validate_mode(cls, v: str) -> str:
|
||||||
|
if v not in ("ro", "rw"):
|
||||||
|
raise ValueError("Mount mode must be 'ro' or 'rw'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"Mount file paths must be relative (got: {path}). "
|
||||||
|
f"The mount target defines the absolute container path."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
@router.get("/defaults/{tool_type_id}")
|
class ConfigProfileCreate(BaseModel):
|
||||||
async def get_default_profile_for_tool_type_endpoint(
|
name: str = Field(description="Profile name (unique per user)")
|
||||||
tool_type_id: str,
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
session: AsyncSession = Depends(get_db_session),
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
) -> dict:
|
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||||
return await get_default_profile_for_tool_type(session, user_id, tool_type_id)
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
|
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
||||||
|
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
||||||
|
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
||||||
|
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict) -> dict:
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
if path.startswith("/"):
|
||||||
|
raise ValueError(
|
||||||
|
f"File paths must be relative (got: {path}). "
|
||||||
|
f"Use Mounts for absolute container paths."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("env_vars")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict) -> dict:
|
||||||
|
result = _validate_env_vars(v)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError("env_vars must be a JSON object")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@field_validator("runtime_hints")
|
||||||
|
@classmethod
|
||||||
|
def validate_runtime_hints(cls, v: dict) -> dict:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("runtime_hints must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("mounts")
|
||||||
|
@classmethod
|
||||||
|
def validate_mounts(cls, v: list) -> list:
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("mounts must be a JSON array")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}")
|
class ConfigProfileUpdate(BaseModel):
|
||||||
async def get_config_profile(
|
name: str | None = Field(default=None, description="Profile name")
|
||||||
profile_id: uuid.UUID,
|
description: str | None = Field(default=None, description="Optional description")
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
project_id: str | None = Field(default=None, description="Optional project ID")
|
||||||
session: AsyncSession = Depends(get_db_session),
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
) -> dict:
|
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||||
profile = await session.get(
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
ConfigProfile,
|
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
||||||
profile_id,
|
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
||||||
options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)],
|
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
||||||
|
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
||||||
|
|
||||||
|
@field_validator("project_id", "tool_type_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_uuids(cls, v: str | None) -> str | None:
|
||||||
|
return _validate_uuid(v)
|
||||||
|
|
||||||
|
@field_validator("files")
|
||||||
|
@classmethod
|
||||||
|
def validate_files(cls, v: dict | None) -> dict | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
for path in v.keys():
|
||||||
|
if ".." in path or path.startswith("/") or not path:
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileIncludeUpdate(BaseModel):
|
||||||
|
includes: list[str] = Field(description="Ordered list of included profile IDs")
|
||||||
|
|
||||||
|
@field_validator("includes")
|
||||||
|
@classmethod
|
||||||
|
def validate_includes(cls, v: list) -> list:
|
||||||
|
for item in v:
|
||||||
|
try:
|
||||||
|
uuid.UUID(item)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid UUID in includes: {item}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
project_id: str | None
|
||||||
|
tool_type_id: str | None
|
||||||
|
env_vars: dict
|
||||||
|
runtime_hints: dict
|
||||||
|
mounts: list
|
||||||
|
files: dict
|
||||||
|
git_mounts: list
|
||||||
|
is_default: bool
|
||||||
|
includes: list[dict]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
||||||
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile_id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
)
|
)
|
||||||
if profile is None or profile.user_id != user_id:
|
return result.scalar_one_or_none()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found")
|
|
||||||
includes_data = []
|
|
||||||
for inc in profile.includes:
|
async def _check_access(
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
session: AsyncSession,
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
user_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
tool_type_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Verify user has access to referenced project and tool type."""
|
||||||
|
if project_id is not None:
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
# Add ownership check if needed; for now just verify existence
|
||||||
|
if tool_type_id is not None:
|
||||||
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
|
if tool_type is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_git_mounts(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
git_mounts: list[dict],
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Validate git mount URLs.
|
||||||
|
|
||||||
|
Simply checks that remote_url looks like a valid git URL.
|
||||||
|
Actual clone validation happens at instance startup time.
|
||||||
|
"""
|
||||||
|
for mount in git_mounts:
|
||||||
|
remote_url = mount.get("remote_url")
|
||||||
|
if not remote_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Git mount missing remote_url",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid git URL: {remote_url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
**profile_to_dict(profile),
|
"id": str(profile.id),
|
||||||
"includes": includes_data,
|
"user_id": str(profile.user_id),
|
||||||
"mounts": [mount_to_dict(m) for m in profile.mounts],
|
"name": profile.name,
|
||||||
|
"description": profile.description,
|
||||||
|
"project_id": str(profile.project_id) if profile.project_id else None,
|
||||||
|
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
|
||||||
|
"env_vars": profile.env_vars or {},
|
||||||
|
"runtime_hints": profile.runtime_hints or {},
|
||||||
|
"mounts": profile.mounts or [],
|
||||||
|
"git_mounts": profile.git_mounts or [],
|
||||||
|
"files": profile.files or {},
|
||||||
|
"is_default": profile.is_default,
|
||||||
|
"includes": [
|
||||||
|
{
|
||||||
|
"id": str(inc.id),
|
||||||
|
"included_profile_id": str(inc.included_profile_id),
|
||||||
|
"order_index": inc.order_index,
|
||||||
|
}
|
||||||
|
for inc in (includes or profile.includes)
|
||||||
|
],
|
||||||
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
|
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}")
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def update_config_profile(
|
async def list_config_profiles(
|
||||||
profile_id: uuid.UUID,
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
data: ConfigProfileUpdate,
|
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
):
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
if data.name is not None:
|
user_uuid = current_user_id
|
||||||
await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id)
|
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
||||||
profile.name = data.name
|
|
||||||
if data.description is not None:
|
if project_id or tool_type_id:
|
||||||
profile.description = data.description
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
|
project_uuid = uuid.UUID(project_id) if project_id else None
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id) if tool_type_id else None
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
conditions: list = []
|
||||||
|
# Portable profiles (no project, no tool)
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
||||||
|
)
|
||||||
|
if project_uuid:
|
||||||
|
# Profiles matching this project (with or without tool)
|
||||||
|
conditions.append(ConfigProfile.project_id == project_uuid)
|
||||||
|
if tool_uuid:
|
||||||
|
# Profiles matching this tool (with or without project)
|
||||||
|
conditions.append(ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
if project_uuid and tool_uuid:
|
||||||
|
# Exact match
|
||||||
|
conditions.append(
|
||||||
|
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.where(or_(*conditions))
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_config_profile(
|
||||||
|
data: ConfigProfileCreate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Create a new config profile."""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == user_uuid,
|
||||||
|
ConfigProfile.name == data.name,
|
||||||
|
).options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{data.name}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = uuid.UUID(data.project_id) if data.project_id else None
|
||||||
|
tool_uuid = uuid.UUID(data.tool_type_id) if data.tool_type_id else None
|
||||||
|
await _check_access(session, user_uuid, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if data.git_mounts:
|
||||||
|
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
||||||
|
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
size = _calculate_profile_size(data.model_dump())
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
user_id=user_uuid,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
project_id=project_uuid,
|
||||||
|
tool_type_id=tool_uuid,
|
||||||
|
env_vars=data.env_vars,
|
||||||
|
runtime_hints=data.runtime_hints,
|
||||||
|
mounts=[m.model_dump() for m in data.mounts],
|
||||||
|
git_mounts=[m.model_dump() for m in data.git_mounts],
|
||||||
|
files=data.files,
|
||||||
|
is_default=data.is_default,
|
||||||
|
)
|
||||||
|
session.add(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(profile)
|
|
||||||
return profile_to_dict(profile)
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Created config profile %s for user %s", profile.id, user_uuid)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def get_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Get a config profile by ID."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{profile_id}", response_model=ConfigProfileResponse)
|
||||||
|
async def update_config_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ConfigProfileUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
"""Update a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Handle name uniqueness
|
||||||
|
if "name" in update_data:
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ConfigProfile).where(
|
||||||
|
ConfigProfile.user_id == profile.user_id,
|
||||||
|
ConfigProfile.name == update_data["name"],
|
||||||
|
ConfigProfile.id != profile.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Profile with name '{update_data['name']}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate references
|
||||||
|
project_uuid = (
|
||||||
|
uuid.UUID(update_data["project_id"])
|
||||||
|
if "project_id" in update_data and update_data["project_id"]
|
||||||
|
else (profile.project_id if "project_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
tool_uuid = (
|
||||||
|
uuid.UUID(update_data["tool_type_id"])
|
||||||
|
if "tool_type_id" in update_data and update_data["tool_type_id"]
|
||||||
|
else (profile.tool_type_id if "tool_type_id" not in update_data else None)
|
||||||
|
)
|
||||||
|
await _check_access(session, profile.user_id, project_uuid, tool_uuid)
|
||||||
|
|
||||||
|
# Validate git mounts reference existing repositories
|
||||||
|
if "git_mounts" in update_data and update_data["git_mounts"] is not None:
|
||||||
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
|
for m in update_data["git_mounts"]
|
||||||
|
]
|
||||||
|
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
|
# Check size
|
||||||
|
current_data = _profile_to_response(profile)
|
||||||
|
merged = {**current_data, **update_data}
|
||||||
|
size = _calculate_profile_size(merged)
|
||||||
|
if size > MAX_PROFILE_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
for field_name, value in update_data.items():
|
||||||
|
if field_name in ("project_id", "tool_type_id"):
|
||||||
|
value = uuid.UUID(value) if value else None
|
||||||
|
elif field_name == "mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
elif field_name == "git_mounts" and value is not None:
|
||||||
|
value = [m.model_dump() if not isinstance(m, dict) else m for m in value]
|
||||||
|
setattr(profile, field_name, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch with includes to avoid lazy loading issues
|
||||||
|
result = await session.execute(
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.id == profile.id)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
profile = result.scalar_one()
|
||||||
|
|
||||||
|
logger.debug("Updated config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_config_profile(
|
async def delete_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: str,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
):
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
"""Delete a config profile."""
|
||||||
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
logger.debug("Deleted config profile %s", profile_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{profile_id}/includes")
|
@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse)
|
||||||
async def list_profile_includes(
|
async def update_profile_includes(
|
||||||
profile_id: uuid.UUID,
|
profile_id: str,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
data: ConfigProfileIncludeUpdate,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
):
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
"""Update the ordered includes for a config profile."""
|
||||||
return await list_includes_for_profile(session, profile_id)
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
|
if profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
|
|
||||||
|
# Validate all included profiles exist and belong to the user
|
||||||
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
|
for inc_uuid in included_uuids:
|
||||||
|
inc_profile = await session.get(ConfigProfile, inc_uuid)
|
||||||
|
if inc_profile is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Included profile not found: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_profile.user_id != current_user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Not authorized to include profile: {inc_uuid}",
|
||||||
|
)
|
||||||
|
if inc_uuid == profile.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Profile cannot include itself",
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED)
|
# Check for cycles
|
||||||
async def add_profile_include(
|
cycle = await check_include_cycle(session, profile.id, None)
|
||||||
profile_id: uuid.UUID,
|
if cycle is None and included_uuids:
|
||||||
data: ConfigIncludeCreate,
|
# Check each new include would not create a cycle
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
for inc_uuid in included_uuids:
|
||||||
session: AsyncSession = Depends(get_db_session),
|
cycle = await check_include_cycle(session, profile.id, inc_uuid)
|
||||||
) -> dict:
|
if cycle is not None:
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
break
|
||||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
|
||||||
if included_profile_id == profile_id:
|
if cycle is not None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself")
|
cycle_str = " -> ".join(str(c) for c in cycle)
|
||||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
raise HTTPException(
|
||||||
if included_profile is None:
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found")
|
detail=f"Include cycle detected: {cycle_str}",
|
||||||
if included_profile.user_id != user_id:
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user")
|
|
||||||
await check_duplicate_include(session, profile_id, included_profile_id)
|
# Remove existing includes
|
||||||
await validate_includes_no_cycle(session, profile_id, included_profile_id)
|
result = await session.execute(
|
||||||
include = ConfigInclude(
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
profile_id=profile_id,
|
|
||||||
included_profile_id=included_profile_id,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
)
|
||||||
session.add(include)
|
for existing in result.scalars().all():
|
||||||
await session.commit()
|
await session.delete(existing)
|
||||||
await session.refresh(include)
|
await session.flush()
|
||||||
return include_to_dict(include, included_profile.name)
|
|
||||||
|
|
||||||
|
# Add new includes
|
||||||
|
for order_index, inc_uuid in enumerate(included_uuids):
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
profile_id=profile.id,
|
||||||
|
included_profile_id=inc_uuid,
|
||||||
|
order_index=order_index,
|
||||||
|
)
|
||||||
|
session.add(include)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
@router.put("/{profile_id}/includes/{include_id}")
|
|
||||||
async def update_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
data: ConfigIncludeUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
include.order_index = data.order_index
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(include)
|
|
||||||
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
|
||||||
return include_to_dict(include, included_profile.name if included_profile else None)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_profile_include(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
include_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> None:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
include = await session.get(ConfigInclude, include_id)
|
|
||||||
if include is None or include.profile_id != profile_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found")
|
|
||||||
await session.delete(include)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Re-fetch profile (includes loaded separately due to SQLite async issue)
|
||||||
|
result = await session.execute(
|
||||||
@router.get("/{profile_id}/mounts")
|
select(ConfigProfile).where(ConfigProfile.id == profile.id)
|
||||||
async def list_profile_mounts(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
|
||||||
return await list_mounts_for_profile(session, profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def add_profile_mount(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
data: ConfigMountCreate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> dict:
|
|
||||||
profile = await get_owned_profile(profile_id, user_id, session)
|
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path)
|
|
||||||
mount = ConfigMount(
|
|
||||||
profile_id=profile_id,
|
|
||||||
target_path=data.target_path,
|
|
||||||
mode=data.mode,
|
|
||||||
files=data.files,
|
|
||||||
order_index=data.order_index,
|
|
||||||
)
|
)
|
||||||
session.add(mount)
|
profile = result.scalar_one()
|
||||||
await session.commit()
|
|
||||||
await session.refresh(mount)
|
inc_result = await session.execute(
|
||||||
return mount_to_dict(mount)
|
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
||||||
|
)
|
||||||
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
|
logger.debug("Updated includes for config profile %s", profile.id)
|
||||||
|
return _profile_to_response(profile, list(direct_includes))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{profile_id}/mounts/{mount_id}")
|
@router.get("/{profile_id}/preview")
|
||||||
async def update_profile_mount(
|
async def preview_config_profile(
|
||||||
profile_id: uuid.UUID,
|
profile_id: str,
|
||||||
mount_id: uuid.UUID,
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
data: ConfigMountUpdate,
|
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
):
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
"""Preview the resolved output of a config profile."""
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if mount is None or mount.profile_id != profile_id:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
||||||
if data.target_path is not None:
|
if profile.user_id != current_user_id:
|
||||||
await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id)
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
||||||
mount.target_path = data.target_path
|
|
||||||
if data.files is not None:
|
try:
|
||||||
mount.files = data.files
|
resolved = await resolve_profile(session, profile.id)
|
||||||
if data.order_index is not None:
|
except ConfigProfileCycleError as exc:
|
||||||
mount.order_index = data.order_index
|
raise HTTPException(
|
||||||
await session.commit()
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
await session.refresh(mount)
|
detail=str(exc),
|
||||||
return mount_to_dict(mount)
|
)
|
||||||
|
|
||||||
|
return resolved_profile_to_dict(resolved)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.get("/defaults/resolve")
|
||||||
async def remove_profile_mount(
|
async def resolve_default_profile(
|
||||||
profile_id: uuid.UUID,
|
project_id: str = Query(..., description="Project ID"),
|
||||||
mount_id: uuid.UUID,
|
tool_type_id: str = Query(..., description="Tool type ID"),
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
):
|
||||||
await get_owned_profile(profile_id, user_id, session)
|
"""Resolve the default config profile for a project/tool combination.
|
||||||
mount = await session.get(ConfigMount, mount_id)
|
|
||||||
if mount is None or mount.profile_id != profile_id:
|
Selects by specificity:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found")
|
1. project+tool explicit default
|
||||||
await session.delete(mount)
|
2. project explicit default
|
||||||
await session.commit()
|
3. tool explicit default
|
||||||
|
4. global/user explicit default
|
||||||
|
5. first created compatible profile
|
||||||
|
6. none (returns null)
|
||||||
|
"""
|
||||||
|
user_uuid = current_user_id
|
||||||
|
project_uuid = uuid.UUID(project_id)
|
||||||
|
tool_uuid = uuid.UUID(tool_type_id)
|
||||||
|
|
||||||
|
# Fetch all compatible profiles ordered by created_at
|
||||||
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.where(
|
||||||
|
(ConfigProfile.project_id.is_(None) & ConfigProfile.tool_type_id.is_(None))
|
||||||
|
| (ConfigProfile.project_id == project_uuid)
|
||||||
|
| (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
| (
|
||||||
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(ConfigProfile.created_at)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
profiles = result.scalars().all()
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
return {"profile_id": None, "profile_name": None}
|
||||||
|
|
||||||
|
# Check explicit defaults by specificity
|
||||||
|
explicit_defaults = [p for p in profiles if p.is_default]
|
||||||
|
|
||||||
|
# Most specific: project+tool
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: project only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id == project_uuid and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: tool only
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id == tool_uuid:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Next: global/user (no project, no tool)
|
||||||
|
for p in explicit_defaults:
|
||||||
|
if p.project_id is None and p.tool_type_id is None:
|
||||||
|
return {"profile_id": str(p.id), "profile_name": p.name}
|
||||||
|
|
||||||
|
# Fall back to first created compatible profile
|
||||||
|
first = profiles[0]
|
||||||
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|||||||
+1279
-142
File diff suppressed because it is too large
Load Diff
@@ -4,18 +4,11 @@ import time
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.schemas.health import (
|
|
||||||
DatabaseHealth,
|
|
||||||
DatabaseHealthResponse,
|
|
||||||
DiskHealth,
|
|
||||||
HealthChecks,
|
|
||||||
HealthResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,6 +16,45 @@ router = APIRouter()
|
|||||||
_start_time = time.time()
|
_start_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealth(BaseModel):
|
||||||
|
"""Database health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
|
class DiskHealth(BaseModel):
|
||||||
|
"""Disk space health check result."""
|
||||||
|
|
||||||
|
status: str = Field(description="Disk health status", examples=["healthy"])
|
||||||
|
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
||||||
|
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthChecks(BaseModel):
|
||||||
|
"""Individual health checks."""
|
||||||
|
|
||||||
|
database: DatabaseHealth | None = None
|
||||||
|
disk: DiskHealth | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Overall health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Overall health status", examples=["healthy"])
|
||||||
|
timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"])
|
||||||
|
version: str = Field(description="API version", examples=["0.1.0"])
|
||||||
|
checks: HealthChecks = Field(description="Individual health checks")
|
||||||
|
uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0])
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseHealthResponse(BaseModel):
|
||||||
|
"""Database-specific health check response."""
|
||||||
|
|
||||||
|
status: str = Field(description="Database health status", examples=["healthy"])
|
||||||
|
response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/health",
|
"/health",
|
||||||
response_model=HealthResponse,
|
response_model=HealthResponse,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|||||||
@@ -3,24 +3,41 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.project import (
|
|
||||||
ProjectCreate,
|
|
||||||
ProjectUpdate,
|
|
||||||
ProjectResponse,
|
|
||||||
SetDefaultSSHKeyRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
default_ssh_key_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
class SetDefaultSSHKeyRequest(BaseModel):
|
||||||
|
ssh_key_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
@@ -31,7 +48,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
)
|
)
|
||||||
async def create_project(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Create a new project.
|
"""Create a new project.
|
||||||
@@ -44,6 +61,7 @@ async def create_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created project.
|
The newly created project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
@@ -63,7 +81,7 @@ async def create_project(
|
|||||||
description="Retrieve all projects owned by the authenticated user.",
|
description="Retrieve all projects owned by the authenticated user.",
|
||||||
)
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[Project]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
@@ -75,6 +93,7 @@ async def list_projects(
|
|||||||
Returns:
|
Returns:
|
||||||
List of projects owned by the user.
|
List of projects owned by the user.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -87,8 +106,7 @@ async def list_projects(
|
|||||||
)
|
)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Get a specific project by ID.
|
"""Get a specific project by ID.
|
||||||
@@ -101,8 +119,8 @@ async def get_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested project.
|
The requested project.
|
||||||
"""
|
"""
|
||||||
return project
|
await _get_user(session, user_id)
|
||||||
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -114,8 +132,7 @@ async def get_project(
|
|||||||
async def update_project(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Update a project.
|
"""Update a project.
|
||||||
@@ -129,6 +146,8 @@ async def update_project(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
project.name = data.name
|
project.name = data.name
|
||||||
@@ -148,8 +167,7 @@ async def update_project(
|
|||||||
)
|
)
|
||||||
async def delete_project(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Delete a project and all its repositories.
|
"""Delete a project and all its repositories.
|
||||||
@@ -162,6 +180,8 @@ async def delete_project(
|
|||||||
Returns:
|
Returns:
|
||||||
Empty response with 204 status code.
|
Empty response with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# Delete repositories from disk and database
|
||||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||||
@@ -185,8 +205,7 @@ async def delete_project(
|
|||||||
async def set_default_ssh_key(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
project: Project = Depends(get_owned_project),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Set the default SSH key for a project.
|
"""Set the default SSH key for a project.
|
||||||
@@ -200,6 +219,8 @@ async def set_default_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated project.
|
The updated project.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
ssh_key = await session.get(SSHKey, data.ssh_key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Shared Pydantic validators for API schemas."""
|
||||||
|
|
||||||
|
|
||||||
|
MAX_FOLDER_SIZE_MB = 10
|
||||||
|
MAX_FOLDER_SIZE_BYTES = MAX_FOLDER_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mount_path(v: str | None) -> str | None:
|
||||||
|
"""Validate that a mount path is absolute (starts with /).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Mount path string or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated path, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is not absolute.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not v.startswith("/"):
|
||||||
|
raise ValueError("Mount path must be absolute (start with /)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_files(v: dict | None, max_size_bytes: int = MAX_FOLDER_SIZE_BYTES) -> dict | None:
|
||||||
|
"""Validate file dict for path traversal and size limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of {path: content} or None.
|
||||||
|
max_size_bytes: Maximum total size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path traversal detected or size limit exceeded.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
for path, content in v.items():
|
||||||
|
# Check for path traversal
|
||||||
|
if ".." in path or path.startswith("/"):
|
||||||
|
raise ValueError(f"Invalid file path: {path}")
|
||||||
|
total_size += len(content.encode("utf-8"))
|
||||||
|
|
||||||
|
if total_size > max_size_bytes:
|
||||||
|
raise ValueError(f"Total folder size exceeds {max_size_bytes // (1024 * 1024)}MB limit")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_env_vars(v: dict | None) -> dict | None:
|
||||||
|
"""Validate that environment variables is a JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: Dict of env vars or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated dict, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a dict.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("environment_variables must be a JSON object")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def validate_volumes(v: list | None) -> list | None:
|
||||||
|
"""Validate volume mounts list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
v: List of volume dicts or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list, or None if input was None.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If not a list or missing required fields.
|
||||||
|
"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if not isinstance(v, list):
|
||||||
|
raise ValueError("volumes must be a JSON array")
|
||||||
|
for i, vol in enumerate(v):
|
||||||
|
if not isinstance(vol, dict):
|
||||||
|
raise ValueError(f"Volume at index {i} must be an object")
|
||||||
|
if "source" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'source' field")
|
||||||
|
if "target" not in vol:
|
||||||
|
raise ValueError(f"Volume at index {i} must have 'target' field")
|
||||||
|
return v
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -5,19 +6,17 @@ from cryptography.fernet import Fernet
|
|||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
import base64
|
import base64
|
||||||
@@ -54,6 +53,36 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
return private_bytes.decode("utf-8"), public_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SSHKeyResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
public_key: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SignPayloadRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureRequest(BaseModel):
|
||||||
|
payload: str
|
||||||
|
signature: str
|
||||||
|
|
||||||
|
|
||||||
|
class VerifySignatureResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=SSHKeyResponse,
|
response_model=SSHKeyResponse,
|
||||||
@@ -63,7 +92,7 @@ def generate_ssh_key_pair() -> tuple[str, str]:
|
|||||||
)
|
)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> SSHKey:
|
||||||
"""Create a new SSH key pair.
|
"""Create a new SSH key pair.
|
||||||
@@ -76,6 +105,7 @@ async def create_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created SSH key with public key exposed.
|
The newly created SSH key with public key exposed.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
fernet = _get_fernet()
|
fernet = _get_fernet()
|
||||||
@@ -100,7 +130,7 @@ async def create_ssh_key(
|
|||||||
description="List all SSH keys for the authenticated user.",
|
description="List all SSH keys for the authenticated user.",
|
||||||
)
|
)
|
||||||
async def list_ssh_keys(
|
async def list_ssh_keys(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> list[SSHKey]:
|
||||||
"""List all SSH keys for the authenticated user.
|
"""List all SSH keys for the authenticated user.
|
||||||
@@ -112,6 +142,7 @@ async def list_ssh_keys(
|
|||||||
Returns:
|
Returns:
|
||||||
List of SSH keys owned by the user.
|
List of SSH keys owned by the user.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -124,7 +155,7 @@ async def list_ssh_keys(
|
|||||||
)
|
)
|
||||||
async def delete_ssh_key(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete an SSH key.
|
"""Delete an SSH key.
|
||||||
@@ -137,9 +168,87 @@ async def delete_ssh_key(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
await session.delete(ssh_key)
|
await session.delete(ssh_key)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/sign",
|
||||||
|
response_model=SignatureResponse,
|
||||||
|
summary="Sign payload",
|
||||||
|
description="Sign a payload using the SSH private key.",
|
||||||
|
)
|
||||||
|
async def sign_payload(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: SignPayloadRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SignatureResponse:
|
||||||
|
"""Sign a payload with an SSH key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for signing.
|
||||||
|
data: Sign request containing the payload string.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded Ed25519 signature.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
private_key = serialization.load_ssh_private_key(
|
||||||
|
private_key_pem.encode(), password=None
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = private_key.sign(data.payload.encode())
|
||||||
|
return SignatureResponse(signature=base64.b64encode(signature).decode())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{key_id}/verify",
|
||||||
|
response_model=VerifySignatureResponse,
|
||||||
|
summary="Verify signature",
|
||||||
|
description="Verify a signature against a payload using the SSH public key.",
|
||||||
|
)
|
||||||
|
async def verify_signature(
|
||||||
|
key_id: uuid.UUID,
|
||||||
|
data: VerifySignatureRequest,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> VerifySignatureResponse:
|
||||||
|
"""Verify a signature with an SSH key's public key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: UUID of the SSH key to use for verification.
|
||||||
|
data: Verify request containing payload and base64-encoded signature.
|
||||||
|
user_id: ID of the authenticated user.
|
||||||
|
session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the signature is valid.
|
||||||
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
||||||
|
|
||||||
|
public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
signature = base64.b64decode(data.signature)
|
||||||
|
public_key.verify(signature, data.payload.encode())
|
||||||
|
return VerifySignatureResponse(valid=True)
|
||||||
|
except Exception:
|
||||||
|
return VerifySignatureResponse(valid=False)
|
||||||
|
|||||||
+219
-53
@@ -4,17 +4,25 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, WebSocket
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
from src.services.terminal_manager import terminal_manager
|
from src.services.terminal_manager import terminal_manager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRef:
|
||||||
|
"""Mutable reference to a terminal session, allowing updates during reset."""
|
||||||
|
|
||||||
|
def __init__(self, session):
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
|
||||||
@router.websocket(
|
@router.websocket(
|
||||||
"/ws/tool-instances/{instance_id}/terminal",
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
)
|
)
|
||||||
@@ -26,11 +34,7 @@ async def terminal_websocket(
|
|||||||
"""WebSocket endpoint for terminal access to a tool instance.
|
"""WebSocket endpoint for terminal access to a tool instance.
|
||||||
|
|
||||||
Provides an interactive terminal session inside a running tool instance container.
|
Provides an interactive terminal session inside a running tool instance container.
|
||||||
Supports:
|
Sessions persist across WebSocket disconnections.
|
||||||
- Auto-reconnection (client reconnects, server spawns new session)
|
|
||||||
- Heartbeat ping/pong
|
|
||||||
- Binary and text input frames
|
|
||||||
- Graceful session end notifications
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
websocket: The WebSocket connection.
|
websocket: The WebSocket connection.
|
||||||
@@ -39,27 +43,27 @@ async def terminal_websocket(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None. Communicates via WebSocket messages.
|
None. Communicates via WebSocket messages.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
logger.info("Terminal WebSocket connection attempt for instance %s", instance_id)
|
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Parse instance_id
|
||||||
instance_uuid = uuid.UUID(instance_id)
|
instance_uuid = uuid.UUID(instance_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("Invalid instance ID: %s", instance_id)
|
logger.error("Invalid instance ID: %s", instance_id)
|
||||||
await websocket.close(code=4001, reason="Invalid instance ID")
|
await websocket.close(code=4001, reason="Invalid instance ID")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Authenticate user from session cookie
|
||||||
user_id = await _get_user_from_websocket(websocket, db_session)
|
user_id = await _get_user_from_websocket(websocket, db_session)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
logger.warning(
|
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
|
||||||
"Unauthorized terminal access attempt for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4003, reason="Unauthorized")
|
await websocket.close(code=4003, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Get instance and verify ownership
|
||||||
instance = await db_session.get(ToolInstance, instance_uuid)
|
instance = await db_session.get(ToolInstance, instance_uuid)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
logger.warning("Instance %s not found", instance_id)
|
logger.warning("Instance %s not found", instance_id)
|
||||||
@@ -67,68 +71,231 @@ async def terminal_websocket(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if instance.owner_id != user_id:
|
if instance.owner_id != user_id:
|
||||||
logger.warning(
|
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
|
||||||
"Forbidden terminal access for instance %s by user %s",
|
|
||||||
instance_id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4003, reason="Forbidden")
|
await websocket.close(code=4003, reason="Forbidden")
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.status != "running" or not instance.container_id:
|
if instance.status != "running" or not instance.container_id:
|
||||||
logger.warning(
|
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
|
||||||
"Instance %s not running (status=%s, container_id=%s)",
|
|
||||||
instance_id,
|
|
||||||
instance.status,
|
|
||||||
instance.container_id,
|
|
||||||
)
|
|
||||||
await websocket.close(code=4004, reason="Instance not running")
|
await websocket.close(code=4004, reason="Instance not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id)
|
||||||
"Creating terminal session for instance %s (container_id=%s)",
|
|
||||||
instance_id,
|
# Fetch tool type to get startup_command
|
||||||
instance.container_id,
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
)
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
if startup_command:
|
||||||
|
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
|
||||||
|
|
||||||
|
# Get or create terminal session
|
||||||
try:
|
try:
|
||||||
session = await terminal_manager.create_session(
|
session = await terminal_manager.get_or_create_session(
|
||||||
instance_uuid,
|
instance_uuid,
|
||||||
instance.container_id,
|
instance.container_id,
|
||||||
websocket,
|
startup_command=startup_command,
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Terminal session created successfully for instance %s",
|
|
||||||
instance_id,
|
|
||||||
)
|
)
|
||||||
|
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
|
||||||
|
|
||||||
|
# Attach WebSocket to session
|
||||||
|
await terminal_manager.attach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket attached to session for instance %s", instance_id)
|
||||||
|
|
||||||
# Send connected status
|
# Send connected status
|
||||||
await websocket.send_json({"type": "status", "status": "connected"})
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
logger.debug("Sent connected status for instance %s", instance_id)
|
||||||
|
|
||||||
# Monitor session health and echo state
|
# Use mutable session reference so loops can survive reset
|
||||||
while session.is_alive() and not session.closed:
|
session_ref = SessionRef(session)
|
||||||
# Check echo state periodically
|
|
||||||
new_echo_state = await session.check_echo_state()
|
|
||||||
if new_echo_state is not None:
|
|
||||||
await websocket.send_json(
|
|
||||||
{"type": "set_echo_state", "enabled": new_echo_state},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(1.0)
|
|
||||||
|
|
||||||
# Session ended — determine reason and notify client
|
# Start I/O loops and heartbeat
|
||||||
exit_reason = session.get_exit_reason() or "process_exit"
|
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
||||||
await websocket.send_json({"type": "session_ended", "reason": exit_reason})
|
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
|
||||||
await websocket.close(code=1000, reason=f"Session ended: {exit_reason}")
|
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
|
||||||
|
logger.debug("Started terminal loops for instance %s", instance_id)
|
||||||
|
|
||||||
except Exception:
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
logger.exception(
|
done, pending = await asyncio.wait(
|
||||||
"Terminal session error for instance %s",
|
[read_task, write_task, heartbeat_task],
|
||||||
instance_id,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
await websocket.close(code=4000, reason="Terminal session error")
|
|
||||||
|
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
|
||||||
|
|
||||||
|
# Cancel remaining tasks
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
|
await websocket.close(code=4000, reason=f"Error: {exc}")
|
||||||
finally:
|
finally:
|
||||||
|
# Detach WebSocket, don't kill session
|
||||||
|
try:
|
||||||
|
if 'session' in locals():
|
||||||
|
await terminal_manager.detach_websocket(session, websocket)
|
||||||
|
logger.debug("WebSocket detached from session for instance %s", instance_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
||||||
|
"""Read output from the container and send to WebSocket."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
data = await session.read_output()
|
||||||
|
if data:
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
|
"""Read input from WebSocket and send to container."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
session = session_ref.session
|
||||||
|
if not session.is_alive() or session._closed:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.receive":
|
||||||
|
if "bytes" in message:
|
||||||
|
await session.write_input(message["bytes"])
|
||||||
|
elif "text" in message:
|
||||||
|
text = message["text"]
|
||||||
|
if text.startswith("{"):
|
||||||
|
# Control message (JSON)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
ctrl = json.loads(text)
|
||||||
|
msg_type = ctrl.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
cols = ctrl.get("cols", 80)
|
||||||
|
rows = ctrl.get("rows", 24)
|
||||||
|
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
|
||||||
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "reset":
|
||||||
|
# Reset terminal session
|
||||||
|
logger.debug("Resetting terminal session for instance %s", session.instance_id)
|
||||||
|
await websocket.send_json({"type": "status", "status": "resetting"})
|
||||||
|
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
session.instance_id,
|
||||||
|
session.container_id,
|
||||||
|
startup_command=session.startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the mutable session reference so read_loop uses the new session
|
||||||
|
session_ref.session = new_session
|
||||||
|
|
||||||
|
# Attach to new session
|
||||||
|
await terminal_manager.attach_websocket(new_session, websocket)
|
||||||
|
await websocket.send_json({"type": "status", "status": "connected"})
|
||||||
|
|
||||||
|
# Continue the loop with the new session
|
||||||
|
continue
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not a valid JSON control message, treat as regular input
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
await session.write_input(text.encode("utf-8"))
|
||||||
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _heartbeat_loop(websocket: WebSocket) -> None:
|
||||||
|
"""Send periodic ping messages to detect disconnections."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30) # Ping every 30 seconds
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "ping"})
|
||||||
|
except Exception:
|
||||||
|
# WebSocket is closed or broken
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
|
||||||
|
summary="Reset terminal session",
|
||||||
|
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
|
||||||
|
)
|
||||||
|
async def reset_terminal_session(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
instance_id: uuid.UUID,
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Reset the terminal session for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: UUID of the project.
|
||||||
|
repo_id: UUID of the repository.
|
||||||
|
instance_id: UUID of the tool instance.
|
||||||
|
db_session: Database session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with status message.
|
||||||
|
"""
|
||||||
|
# Get instance and verify it exists and is running
|
||||||
|
instance = await db_session.get(ToolInstance, instance_id)
|
||||||
|
if instance is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Instance not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if instance.status != "running" or not instance.container_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Instance is not running"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch tool type to get startup_command
|
||||||
|
tool_type = await db_session.get(ToolType, instance.tool_type_id)
|
||||||
|
startup_command = tool_type.startup_command if tool_type else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reset the session
|
||||||
|
new_session = await terminal_manager.reset_session(
|
||||||
|
instance_id,
|
||||||
|
instance.container_id,
|
||||||
|
startup_command=startup_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Terminal session reset successfully",
|
||||||
|
"instance_id": str(instance_id),
|
||||||
|
"session_id": new_session.session_id,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to reset terminal session: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_user_from_websocket(
|
async def _get_user_from_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
@@ -141,7 +308,6 @@ async def _get_user_from_websocket(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The user's UUID if authenticated, None otherwise.
|
The user's UUID if authenticated, None otherwise.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from src.auth.session import decode_session_cookie
|
from src.auth.session import decode_session_cookie
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|||||||
@@ -1,22 +1,99 @@
|
|||||||
"""Tool configuration API endpoints."""
|
"""Tool configuration API endpoints."""
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
||||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
from src.models.tool_config import ToolConfig
|
from src.models.tool_config import ToolConfig
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigCreate(BaseModel):
|
||||||
|
tool_type_id: str = Field(description="UUID of the tool type")
|
||||||
|
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
||||||
|
key: str = Field(description="Config key name")
|
||||||
|
value: str = Field(description="Config value")
|
||||||
|
config_type: str = Field(default="env", description="Type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
return _validate_env_vars(v)
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
return _validate_volumes(v)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigUpdate(BaseModel):
|
||||||
|
key: str | None = Field(default=None, description="Config key name")
|
||||||
|
value: str | None = Field(default=None, description="Config value")
|
||||||
|
config_type: str | None = Field(default=None, description="Type: env or file")
|
||||||
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
||||||
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
||||||
|
start_command: str | None = Field(default=None, description="Override container start command")
|
||||||
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
||||||
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
||||||
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
||||||
|
|
||||||
|
@field_validator("port_override")
|
||||||
|
@classmethod
|
||||||
|
def validate_port(cls, v: int | None) -> int | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v < 1 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("environment_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
||||||
|
return _validate_env_vars(v)
|
||||||
|
|
||||||
|
@field_validator("volumes")
|
||||||
|
@classmethod
|
||||||
|
def validate_volumes(cls, v: list | None) -> list | None:
|
||||||
|
return _validate_volumes(v)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolConfigResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tool_type_id: str
|
||||||
|
project_id: str | None
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
config_type: str
|
||||||
|
file_path: str | None
|
||||||
|
port_override: int | None
|
||||||
|
start_command: str | None
|
||||||
|
working_directory: str | None
|
||||||
|
environment_variables: dict | None
|
||||||
|
volumes: list[dict] | None
|
||||||
|
|
||||||
|
|
||||||
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
||||||
async def list_configs(
|
async def list_configs(
|
||||||
tool_type_id: str | None = None,
|
tool_type_id: str | None = None,
|
||||||
|
|||||||
+1941
-148
File diff suppressed because it is too large
Load Diff
+248
-70
@@ -1,15 +1,19 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.api.tool_types_validation import (
|
||||||
|
check_port_exposed,
|
||||||
|
validate_compose_yaml,
|
||||||
|
validate_required_variables,
|
||||||
|
)
|
||||||
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||||
|
|
||||||
@@ -25,6 +29,203 @@ async def _require_admin(user: User) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int = 0
|
||||||
|
definition_type: str = "compose"
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
|
required_variables: list[str] = []
|
||||||
|
category: str = "other"
|
||||||
|
interface_type: str = "web"
|
||||||
|
requires_port: bool = True
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
validate_compose_yaml(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if v is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str) -> str:
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("default_port")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_port(cls, v: int, info) -> int:
|
||||||
|
data = info.data
|
||||||
|
requires_port = data.get("requires_port", True)
|
||||||
|
if not requires_port:
|
||||||
|
return v
|
||||||
|
if v <= 0 or v > 65535:
|
||||||
|
raise ValueError("Port must be between 1 and 65535")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("required_variables")
|
||||||
|
@classmethod
|
||||||
|
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
||||||
|
if not v:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
if data.get("definition_type") != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
template = data.get("compose_template")
|
||||||
|
if not template:
|
||||||
|
return v
|
||||||
|
|
||||||
|
for var in v:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise ValueError(f"Required variable '{var}' not found in compose template")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
|
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
||||||
|
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
||||||
|
if self.definition_type == "compose" and self.compose_template is None:
|
||||||
|
raise ValueError("compose_template is required when definition_type is 'compose'")
|
||||||
|
|
||||||
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
|
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
||||||
|
try:
|
||||||
|
parsed = validate_compose_yaml(self.compose_template)
|
||||||
|
except ValueError:
|
||||||
|
return self
|
||||||
|
|
||||||
|
if not check_port_exposed(parsed, self.default_port):
|
||||||
|
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
default_port: int | None = None
|
||||||
|
definition_type: str | None = None
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
build_context: dict | None = None
|
||||||
|
readiness_probe: dict | None = None
|
||||||
|
startup_command: str | None = None
|
||||||
|
required_variables: list[str] | None = None
|
||||||
|
category: str | None = None
|
||||||
|
interface_type: str | None = None
|
||||||
|
requires_port: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("definition_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_definition_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("compose", "dockerfile"):
|
||||||
|
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("interface_type")
|
||||||
|
@classmethod
|
||||||
|
def validate_interface_type(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v not in ("web", "terminal"):
|
||||||
|
raise ValueError("interface_type must be 'web' or 'terminal'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("compose_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "compose":
|
||||||
|
return v
|
||||||
|
|
||||||
|
validate_compose_yaml(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("dockerfile_template")
|
||||||
|
@classmethod
|
||||||
|
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
|
||||||
|
data = info.data
|
||||||
|
definition_type = data.get("definition_type")
|
||||||
|
if definition_type and definition_type != "dockerfile":
|
||||||
|
return v
|
||||||
|
|
||||||
|
if not v.strip().startswith("FROM"):
|
||||||
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
description: str | None
|
||||||
|
category: str
|
||||||
|
interface_type: str
|
||||||
|
requires_port: bool
|
||||||
|
default_port: int
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None
|
||||||
|
dockerfile_template: str | None
|
||||||
|
build_context: dict | None
|
||||||
|
readiness_probe: dict | None
|
||||||
|
startup_command: str | None
|
||||||
|
required_variables: list[str]
|
||||||
|
created_by_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
response_model=ToolTypeResponse,
|
response_model=ToolTypeResponse,
|
||||||
@@ -34,7 +235,7 @@ async def _require_admin(user: User) -> None:
|
|||||||
)
|
)
|
||||||
async def create_tool_type(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Create a new tool type.
|
"""Create a new tool type.
|
||||||
@@ -47,6 +248,7 @@ async def create_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The newly created tool type.
|
The newly created tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
@@ -64,10 +266,11 @@ async def create_tool_type(
|
|||||||
dockerfile_template=data.dockerfile_template,
|
dockerfile_template=data.dockerfile_template,
|
||||||
build_context=data.build_context,
|
build_context=data.build_context,
|
||||||
readiness_probe=data.readiness_probe,
|
readiness_probe=data.readiness_probe,
|
||||||
|
startup_command=data.startup_command,
|
||||||
required_variables=data.required_variables,
|
required_variables=data.required_variables,
|
||||||
category=data.category,
|
category=data.category,
|
||||||
interfaces=data.interfaces,
|
interface_type=data.interface_type,
|
||||||
is_builtin=False,
|
requires_port=data.requires_port,
|
||||||
created_by_id=user.id,
|
created_by_id=user.id,
|
||||||
)
|
)
|
||||||
session.add(tool_type)
|
session.add(tool_type)
|
||||||
@@ -83,7 +286,7 @@ async def create_tool_type(
|
|||||||
description="List all available tool types including built-in and custom ones.",
|
description="List all available tool types including built-in and custom ones.",
|
||||||
)
|
)
|
||||||
async def list_tool_types(
|
async def list_tool_types(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> list[ToolType]:
|
||||||
"""List all tool types.
|
"""List all tool types.
|
||||||
@@ -95,6 +298,7 @@ async def list_tool_types(
|
|||||||
Returns:
|
Returns:
|
||||||
List of all tool types ordered by name.
|
List of all tool types ordered by name.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -107,7 +311,7 @@ async def list_tool_types(
|
|||||||
)
|
)
|
||||||
async def get_tool_type(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Get a specific tool type by ID.
|
"""Get a specific tool type by ID.
|
||||||
@@ -120,6 +324,7 @@ async def get_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The requested tool type.
|
The requested tool type.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
@@ -135,7 +340,7 @@ async def get_tool_type(
|
|||||||
async def update_tool_type(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> ToolType:
|
||||||
"""Update a tool type.
|
"""Update a tool type.
|
||||||
@@ -149,19 +354,20 @@ async def update_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated tool type.
|
The updated tool type.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be modified
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types")
|
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
# Validate port if being updated
|
# Validate port if being updated
|
||||||
if "default_port" in update_data:
|
requires_port = update_data.get("requires_port", tool_type.requires_port)
|
||||||
|
if "default_port" in update_data and requires_port:
|
||||||
new_port = update_data["default_port"]
|
new_port = update_data["default_port"]
|
||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -175,53 +381,29 @@ async def update_tool_type(
|
|||||||
template = update_data.get("compose_template", tool_type.compose_template)
|
template = update_data.get("compose_template", tool_type.compose_template)
|
||||||
if template:
|
if template:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(template)
|
parsed = validate_compose_yaml(template)
|
||||||
except yaml.YAMLError:
|
if not check_port_exposed(parsed, new_port):
|
||||||
parsed = None
|
|
||||||
|
|
||||||
if parsed and isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
port_str = str(new_port)
|
|
||||||
port_exposed = False
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == new_port:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not port_exposed:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template"
|
detail=f"Port {new_port} is not exposed in the compose template"
|
||||||
)
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
definition_type = update_data.get("definition_type", tool_type.definition_type)
|
||||||
if definition_type == "compose":
|
if definition_type == "compose":
|
||||||
if "required_variables" in update_data and "compose_template" in update_data:
|
if "required_variables" in update_data and "compose_template" in update_data:
|
||||||
template = update_data["compose_template"]
|
validate_required_variables(
|
||||||
for var in update_data["required_variables"]:
|
update_data["compose_template"], update_data["required_variables"]
|
||||||
placeholder = f"{{{{{var}}}}}"
|
)
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
elif "required_variables" in update_data:
|
elif "required_variables" in update_data:
|
||||||
template = tool_type.compose_template
|
template = tool_type.compose_template
|
||||||
if template:
|
if template:
|
||||||
for var in update_data["required_variables"]:
|
validate_required_variables(template, update_data["required_variables"])
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Required variable '{var}' not found in compose template"
|
|
||||||
)
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(tool_type, field, value)
|
setattr(tool_type, field, value)
|
||||||
@@ -231,6 +413,12 @@ async def update_tool_type(
|
|||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
|
class ToolTypeValidateRequest(BaseModel):
|
||||||
|
definition_type: str
|
||||||
|
compose_template: str | None = None
|
||||||
|
dockerfile_template: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/validate",
|
"/validate",
|
||||||
summary="Validate tool type template",
|
summary="Validate tool type template",
|
||||||
@@ -238,7 +426,7 @@ async def update_tool_type(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type_template(
|
async def validate_tool_type_template(
|
||||||
data: ToolTypeValidateRequest,
|
data: ToolTypeValidateRequest,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type template syntax.
|
"""Validate a tool type template syntax.
|
||||||
@@ -251,6 +439,7 @@ async def validate_tool_type_template(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -259,15 +448,9 @@ async def validate_tool_type_template(
|
|||||||
errors.append("Compose template is required")
|
errors.append("Compose template is required")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(data.compose_template)
|
validate_compose_yaml(data.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif data.definition_type == "dockerfile":
|
elif data.definition_type == "dockerfile":
|
||||||
if not data.dockerfile_template:
|
if not data.dockerfile_template:
|
||||||
@@ -291,7 +474,7 @@ async def validate_tool_type_template(
|
|||||||
)
|
)
|
||||||
async def validate_tool_type(
|
async def validate_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Validate a tool type's template syntax.
|
"""Validate a tool type's template syntax.
|
||||||
@@ -304,6 +487,7 @@ async def validate_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
Validation result with success status and any errors.
|
Validation result with success status and any errors.
|
||||||
"""
|
"""
|
||||||
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
@@ -315,15 +499,9 @@ async def validate_tool_type(
|
|||||||
errors.append("Compose template is empty")
|
errors.append("Compose template is empty")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
parsed = yaml.safe_load(tool_type.compose_template)
|
validate_compose_yaml(tool_type.compose_template)
|
||||||
if not isinstance(parsed, dict):
|
except ValueError as e:
|
||||||
errors.append("Compose template must be a YAML mapping")
|
errors.append(str(e))
|
||||||
elif "services" not in parsed:
|
|
||||||
errors.append("Compose template must contain 'services' key")
|
|
||||||
elif not parsed["services"]:
|
|
||||||
errors.append("Compose template must define at least one service")
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
errors.append(f"Invalid YAML: {e}")
|
|
||||||
|
|
||||||
elif tool_type.definition_type == "dockerfile":
|
elif tool_type.definition_type == "dockerfile":
|
||||||
if not tool_type.dockerfile_template:
|
if not tool_type.dockerfile_template:
|
||||||
@@ -345,7 +523,7 @@ async def validate_tool_type(
|
|||||||
)
|
)
|
||||||
async def delete_tool_type(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a tool type.
|
"""Delete a tool type.
|
||||||
@@ -358,14 +536,14 @@ async def delete_tool_type(
|
|||||||
Returns:
|
Returns:
|
||||||
None with 204 status code.
|
None with 204 status code.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
||||||
|
|
||||||
if tool_type.is_builtin:
|
# Built-in tool types can now be deleted
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types")
|
|
||||||
|
|
||||||
await session.delete(tool_type)
|
await session.delete(tool_type)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Shared validation utilities for tool types."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_template_vars(template: str) -> str:
|
||||||
|
"""Replace template variables like {{VAR}} with placeholders to avoid YAML parsing errors."""
|
||||||
|
return re.sub(r"\{\{[A-Za-z_][A-Za-z0-9_]*\}\}", "__PLACEHOLDER__", template)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_compose_yaml(template: str) -> dict:
|
||||||
|
"""Validate and parse a compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Raw compose template string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed YAML dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If YAML is invalid or missing required keys.
|
||||||
|
"""
|
||||||
|
sanitized = sanitize_template_vars(template)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(sanitized)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise ValueError(f"Invalid YAML: {e}")
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Compose template must be a YAML mapping")
|
||||||
|
|
||||||
|
if "services" not in parsed:
|
||||||
|
raise ValueError("Compose template must contain 'services' key")
|
||||||
|
|
||||||
|
if not parsed["services"]:
|
||||||
|
raise ValueError("Compose template must define at least one service")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def check_port_exposed(parsed: dict, port: int) -> bool:
|
||||||
|
"""Check if a port is exposed in a parsed compose template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parsed: Parsed compose YAML dict.
|
||||||
|
port: Port number to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if port is exposed in any service.
|
||||||
|
"""
|
||||||
|
port_str = str(port)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict) or "services" not in parsed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for service_config in parsed["services"].values():
|
||||||
|
if isinstance(service_config, dict) and "ports" in service_config:
|
||||||
|
for port_mapping in service_config["ports"]:
|
||||||
|
if isinstance(port_mapping, str) and port_str in port_mapping:
|
||||||
|
return True
|
||||||
|
elif isinstance(port_mapping, int) and port_mapping == port:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required_variables(template: str, variables: list[str]) -> None:
|
||||||
|
"""Validate that all required variables exist in the template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Compose template string.
|
||||||
|
variables: List of required variable names.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If any variable is not found in the template.
|
||||||
|
"""
|
||||||
|
for var in variables:
|
||||||
|
placeholder = f"{{{{{var}}}}}"
|
||||||
|
if placeholder not in template:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Required variable '{var}' not found in compose template",
|
||||||
|
)
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.user import User
|
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
from src.schemas.user_config import UserConfigResponse, UserConfigUpdate
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
@@ -24,16 +24,34 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
The user's config, creating a new one if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id))
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = UserConfig(user_id=user.id, config={})
|
config = UserConfig(user_id=user_id, config={})
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
default_editor: str | None = None
|
||||||
|
theme: str = "system"
|
||||||
|
git_user_name: str | None = None
|
||||||
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserConfigUpdate(BaseModel):
|
||||||
|
default_editor: str | None = None
|
||||||
|
theme: str | None = None
|
||||||
|
git_user_name: str | None = None
|
||||||
|
git_user_email: str | None = None
|
||||||
|
last_session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/config",
|
"/config",
|
||||||
response_model=UserConfigResponse,
|
response_model=UserConfigResponse,
|
||||||
@@ -41,7 +59,7 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
description="Get the current user's configuration settings.",
|
description="Get the current user's configuration settings.",
|
||||||
)
|
)
|
||||||
async def get_user_config(
|
async def get_user_config(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Get the current user's configuration.
|
"""Get the current user's configuration.
|
||||||
@@ -53,7 +71,8 @@ async def get_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's configuration settings.
|
The user's configuration settings.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +84,7 @@ async def get_user_config(
|
|||||||
)
|
)
|
||||||
async def update_user_config(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> UserConfigResponse:
|
||||||
"""Update the current user's configuration.
|
"""Update the current user's configuration.
|
||||||
@@ -78,15 +97,16 @@ async def update_user_config(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user configuration.
|
The updated user configuration.
|
||||||
"""
|
"""
|
||||||
config = await _get_or_create_config(session, user.id)
|
_user = await _get_user(session, user_id)
|
||||||
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
# Merge updates
|
# Merge updates
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
logger.info("Updating user config for user %s: %s", user.id, update_data)
|
logger.debug("Updating user config for user %s: %s", user_id, update_data)
|
||||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||||
config.config = {**config.config, **update_data}
|
config.config = {**config.config, **update_data}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
logger.info("Updated config: %s", config.config)
|
logger.debug("Updated config: %s", config.config)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
return UserConfigResponse.model_validate(config.config)
|
||||||
|
|||||||
+21
-47
@@ -2,14 +2,11 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import _get_user, get_current_user_id, get_db_session
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
|
||||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -19,6 +16,19 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
|||||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
name: str
|
||||||
|
avatar_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/me",
|
"/me",
|
||||||
@@ -27,7 +37,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|||||||
description="Retrieve the profile of the currently authenticated user.",
|
description="Retrieve the profile of the currently authenticated user.",
|
||||||
)
|
)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Get the current user's profile.
|
"""Get the current user's profile.
|
||||||
@@ -39,7 +49,7 @@ async def get_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The user's profile information.
|
The user's profile information.
|
||||||
"""
|
"""
|
||||||
return user
|
return await _get_user(session, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -50,7 +60,7 @@ async def get_profile(
|
|||||||
)
|
)
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Update the current user's profile.
|
"""Update the current user's profile.
|
||||||
@@ -63,6 +73,7 @@ async def update_profile(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile.
|
The updated user profile.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
if len(data.name.strip()) == 0:
|
if len(data.name.strip()) == 0:
|
||||||
@@ -87,7 +98,7 @@ async def update_profile(
|
|||||||
)
|
)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Upload a profile avatar image.
|
"""Upload a profile avatar image.
|
||||||
@@ -100,6 +111,7 @@ async def upload_avatar(
|
|||||||
Returns:
|
Returns:
|
||||||
The updated user profile with new avatar URL.
|
The updated user profile with new avatar URL.
|
||||||
"""
|
"""
|
||||||
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -134,41 +146,3 @@ async def upload_avatar(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/me/sessions",
|
|
||||||
response_model=SessionListResponse,
|
|
||||||
summary="Get current user sessions",
|
|
||||||
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
|
||||||
)
|
|
||||||
async def get_user_sessions(
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> SessionListResponse:
|
|
||||||
"""Return all tool instances for the current user with related names."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance)
|
|
||||||
.where(ToolInstance.owner_id == user.id)
|
|
||||||
.order_by(ToolInstance.created_at.desc())
|
|
||||||
)
|
|
||||||
instances = result.scalars().all()
|
|
||||||
|
|
||||||
sessions = [
|
|
||||||
SessionItemResponse(
|
|
||||||
id=str(inst.id),
|
|
||||||
display_name=inst.display_name,
|
|
||||||
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
|
||||||
tool_icon=inst.tool_type.icon if inst.tool_type else None,
|
|
||||||
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
|
||||||
repository_name=inst.repository.name if inst.repository else "Unknown",
|
|
||||||
repository_id=str(inst.repository_id),
|
|
||||||
project_name=inst.project.name if inst.project else "Unknown",
|
|
||||||
project_id=str(inst.project_id),
|
|
||||||
status=inst.status,
|
|
||||||
url=inst.url,
|
|
||||||
)
|
|
||||||
for inst in instances
|
|
||||||
]
|
|
||||||
|
|
||||||
return SessionListResponse(sessions=sessions)
|
|
||||||
|
|||||||
@@ -50,17 +50,25 @@ async def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_project(
|
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||||
|
"""Fetch a user by ID or raise 401 if not found."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user_id: uuid.UUID,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> "Project":
|
||||||
"""Fetch a project and verify ownership.
|
"""Fetch a project and verify ownership.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: UUID of the project (injected from path parameter).
|
project_id: UUID of the project.
|
||||||
user: The currently authenticated user.
|
user_id: ID of the authenticated user.
|
||||||
db_session: Database session.
|
session: Database session.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The project if found and owned by the user.
|
The project if found and owned by the user.
|
||||||
@@ -68,9 +76,11 @@ async def get_owned_project(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: 404 if project not found, 403 if user is not the owner.
|
HTTPException: 404 if project not found, 403 if user is not the owner.
|
||||||
"""
|
"""
|
||||||
project = await db_session.get(Project, project_id)
|
from src.models.project import Project
|
||||||
|
|
||||||
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
if project.owner_id != user.id:
|
if project.owner_id != user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner")
|
||||||
return project
|
return project
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,6 +6,7 @@ from fastapi.exceptions import RequestValidationError
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.git_repositories import router as git_repositories_router
|
||||||
@@ -24,13 +24,12 @@ from src.api.tool_types import router as tool_types_router
|
|||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.database import SessionLocal, init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
ExceptionLoggingMiddleware,
|
ExceptionLoggingMiddleware,
|
||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
from src.seeds.builtin_tool_types import seed_builtin_tool_types
|
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -113,8 +112,6 @@ async def on_startup():
|
|||||||
import sys
|
import sys
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Seed built-in data
|
|
||||||
await seed_builtin_tool_types()
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_folder import ConfigFolder
|
from src.models.config_folder import ConfigFolder
|
||||||
from src.models.config_include import ConfigInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -11,17 +9,4 @@ from src.models.tool_type import ToolType
|
|||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"]
|
||||||
"Base",
|
|
||||||
"ConfigFolder",
|
|
||||||
"ConfigInclude",
|
|
||||||
"ConfigMount",
|
|
||||||
"ConfigProfile",
|
|
||||||
"GitRepository",
|
|
||||||
"Project",
|
|
||||||
"SSHKey",
|
|
||||||
"ToolInstance",
|
|
||||||
"ToolType",
|
|
||||||
"User",
|
|
||||||
"UserConfig",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
project_overrides: Mapped[dict | None] = mapped_column(
|
project_overrides: Mapped[dict | None] = mapped_column(
|
||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
) # {"project_id": {"mount_path": "...", "files": {...}}}
|
||||||
# DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time
|
|
||||||
# auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead.
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_includes"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"),
|
|
||||||
)
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="includes",
|
|
||||||
)
|
|
||||||
included_profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[included_profile_id],
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String
|
|
||||||
from sqlalchemy import Uuid as UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|
||||||
__tablename__ = "config_mounts"
|
|
||||||
|
|
||||||
profile_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
target_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw")
|
|
||||||
files: Mapped[dict[str, str] | None] = mapped_column(
|
|
||||||
JSON, default=dict, nullable=True
|
|
||||||
)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
|
|
||||||
profile: Mapped["ConfigProfile"] = relationship(
|
|
||||||
"ConfigProfile",
|
|
||||||
foreign_keys=[profile_id],
|
|
||||||
back_populates="mounts",
|
|
||||||
)
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
@@ -17,43 +15,63 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
__tablename__ = "config_profiles"
|
__tablename__ = "config_profiles"
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
project_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
tool_type_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
env_vars: Mapped[dict] = mapped_column(
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
JSON, default=dict, nullable=False
|
||||||
environment_variables: Mapped[dict[str, str] | None] = mapped_column(
|
) # {"VAR_NAME": "value", ...}
|
||||||
JSON, default=dict, nullable=True
|
runtime_hints: Mapped[dict] = mapped_column(
|
||||||
)
|
JSON, default=dict, nullable=False
|
||||||
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
) # {"start_command": "...", "working_dir": "...", ...}
|
||||||
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
|
mounts: Mapped[list] = mapped_column(
|
||||||
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
JSON, default=list, nullable=False
|
||||||
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...]
|
||||||
|
files: Mapped[dict] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
) # {"rel/path": "content", ...}
|
||||||
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
project: Mapped["Project | None"] = relationship()
|
project: Mapped["Project | None"] = relationship()
|
||||||
tool_type: Mapped["ToolType | None"] = relationship()
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
"ConfigInclude",
|
"ConfigProfileInclude",
|
||||||
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
back_populates="profile",
|
order_by="ConfigProfileInclude.order_index",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
order_by="ConfigInclude.order_index",
|
|
||||||
)
|
)
|
||||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
|
||||||
"ConfigMount",
|
|
||||||
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
|
class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
back_populates="profile",
|
__tablename__ = "config_profile_includes"
|
||||||
cascade="all, delete-orphan",
|
|
||||||
order_by="ConfigMount.order_index",
|
profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
included_profile_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[profile_id],
|
||||||
|
back_populates="includes",
|
||||||
|
)
|
||||||
|
included_profile: Mapped["ConfigProfile"] = relationship(
|
||||||
|
"ConfigProfile",
|
||||||
|
foreign_keys=[included_profile_id],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -18,11 +19,15 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
path: Mapped[str] = mapped_column(String(1024))
|
path: Mapped[str] = mapped_column(String(1024))
|
||||||
project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False)
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True)
|
||||||
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False)
|
||||||
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("ssh_keys.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship(back_populates="repositories")
|
project: Mapped["Project"] = relationship(back_populates="repositories")
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
ssh_key: Mapped["SSHKey | None"] = relationship()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy import Uuid as UUID
|
from sqlalchemy import Uuid as UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -63,7 +63,16 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
last_stopped_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=True
|
DateTime(timezone=True), nullable=True
|
||||||
)
|
)
|
||||||
selected_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
probe_result: Mapped[dict | None] = mapped_column(
|
||||||
|
JSON, nullable=True
|
||||||
|
)
|
||||||
|
clone_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="mount"
|
||||||
|
)
|
||||||
|
branch: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, default="main"
|
||||||
|
)
|
||||||
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,4 +80,4 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
selected_profile: Mapped["ConfigProfile | None"] = relationship()
|
selected_config_profile: Mapped["ConfigProfile | None"] = relationship()
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(50), nullable=False, default="other")
|
||||||
interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
interface_type: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||||
|
requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
default_port: Mapped[int] = mapped_column(nullable=False)
|
default_port: Mapped[int] = mapped_column(nullable=False)
|
||||||
definition_type: Mapped[str] = mapped_column(
|
definition_type: Mapped[str] = mapped_column(
|
||||||
String(20), nullable=False, default="compose"
|
String(20), nullable=False, default="compose"
|
||||||
@@ -29,8 +30,8 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
JSON, default=dict, nullable=True
|
JSON, default=dict, nullable=True
|
||||||
)
|
)
|
||||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
startup_command: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(),
|
UUID(),
|
||||||
ForeignKey("users.id"),
|
ForeignKey("users.id"),
|
||||||
|
|||||||
@@ -18,23 +18,3 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="user_config")
|
user: Mapped["User"] = relationship(back_populates="user_config")
|
||||||
|
|
||||||
@property
|
|
||||||
def default_profile_id(self) -> uuid.UUID | None:
|
|
||||||
profile_id = self.config.get("default_profile_id")
|
|
||||||
return uuid.UUID(profile_id) if profile_id else None
|
|
||||||
|
|
||||||
@default_profile_id.setter
|
|
||||||
def default_profile_id(self, value: uuid.UUID | None) -> None:
|
|
||||||
if value is not None:
|
|
||||||
self.config["default_profile_id"] = str(value)
|
|
||||||
elif "default_profile_id" in self.config:
|
|
||||||
del self.config["default_profile_id"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_profiles(self) -> dict[str, str]:
|
|
||||||
return self.config.get("default_profiles", {})
|
|
||||||
|
|
||||||
@default_profiles.setter
|
|
||||||
def default_profiles(self, value: dict[str, str]) -> None:
|
|
||||||
self.config["default_profiles"] = value
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Pydantic request/response schemas."""
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Config folder request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderCreate(BaseModel):
|
|
||||||
name: str = Field(description="Folder name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
mount_path: str = Field(description="Mount path in container")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
is_active: bool = Field(default=True, description="Whether folder is active")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
mount_path: str | None = None
|
|
||||||
files: dict[str, str] | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectOverrideCreate(BaseModel):
|
|
||||||
project_id: str = Field(description="Project ID to override for")
|
|
||||||
mount_path: str | None = Field(default=None, description="Override mount path")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Override files"
|
|
||||||
)
|
|
||||||
is_active: bool | None = Field(default=None, description="Override active state")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigFolderResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
mount_path: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
is_active: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Config profile request/response schemas."""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
MAX_MOUNT_PATH_LENGTH = 1024
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileCreate(BaseModel):
|
|
||||||
name: str = Field(description="Profile name (unique per user)")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileUpdate(BaseModel):
|
|
||||||
name: str | None = Field(default=None, description="Profile name")
|
|
||||||
description: str | None = Field(default=None, description="Optional description")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
v = v.strip()
|
|
||||||
if not v:
|
|
||||||
raise ValueError("Profile name cannot be empty")
|
|
||||||
if len(v) > 255:
|
|
||||||
raise ValueError("Profile name must be 255 characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
|
||||||
includes: list[dict[str, Any]]
|
|
||||||
mounts: list[dict[str, Any]]
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeCreate(BaseModel):
|
|
||||||
included_profile_id: str = Field(description="UUID of the profile to include")
|
|
||||||
order_index: int = Field(default=0, description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeUpdate(BaseModel):
|
|
||||||
order_index: int = Field(description="Order index for include resolution")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigIncludeResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
included_profile_id: str
|
|
||||||
included_profile_name: str | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountCreate(BaseModel):
|
|
||||||
target_path: str = Field(description="Absolute target path in container")
|
|
||||||
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int = Field(default=0, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str) -> str:
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountUpdate(BaseModel):
|
|
||||||
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
|
||||||
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
|
||||||
files: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Files as {path: content}"
|
|
||||||
)
|
|
||||||
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
|
||||||
|
|
||||||
@field_validator("target_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_target_path(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not v.startswith("/"):
|
|
||||||
raise ValueError("Target path must be absolute (start with /)")
|
|
||||||
if ".." in v:
|
|
||||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
|
||||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
|
||||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigMountResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
profile_id: str
|
|
||||||
target_path: str
|
|
||||||
mode: str
|
|
||||||
files: dict[str, str] | None
|
|
||||||
order_index: int
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class DefaultProfilesUpdate(BaseModel):
|
|
||||||
default_profiles: dict[str, str] = Field(
|
|
||||||
description="Mapping of tool_type_id to profile_id"
|
|
||||||
)
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
"""Git repository request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class GitRepositoryCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
remote_url: str | None = None
|
|
||||||
force_original_url: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class URLParseRequest(BaseModel):
|
|
||||||
url: str
|
|
||||||
|
|
||||||
|
|
||||||
class URLParseResponse(BaseModel):
|
|
||||||
original_url: str
|
|
||||||
base_url: str | None
|
|
||||||
is_valid_clone_url: bool
|
|
||||||
needs_parsing: bool
|
|
||||||
host: str | None
|
|
||||||
message: str
|
|
||||||
error_code: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class GitRepositoryResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
path: str
|
|
||||||
project_id: uuid.UUID
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
is_mirror: bool
|
|
||||||
remote_url: str | None
|
|
||||||
last_push: datetime | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class FileListResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
entries: list[dict]
|
|
||||||
|
|
||||||
|
|
||||||
class FileContentResponse(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
content: str
|
|
||||||
size: int
|
|
||||||
encoding: str
|
|
||||||
language: str | None
|
|
||||||
is_binary: bool
|
|
||||||
last_commit: dict | None
|
|
||||||
|
|
||||||
|
|
||||||
class BranchesResponse(BaseModel):
|
|
||||||
branches: list[dict]
|
|
||||||
default_branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpdateRequest(BaseModel):
|
|
||||||
path: str
|
|
||||||
branch: str
|
|
||||||
content: str
|
|
||||||
commit_message: str
|
|
||||||
|
|
||||||
|
|
||||||
class FileUpdateResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class StatusResponse(BaseModel):
|
|
||||||
branch: str
|
|
||||||
modified: list[str]
|
|
||||||
added: list[str]
|
|
||||||
deleted: list[str]
|
|
||||||
untracked: list[str]
|
|
||||||
renamed: list[str]
|
|
||||||
ahead: int
|
|
||||||
behind: int
|
|
||||||
|
|
||||||
|
|
||||||
class BranchCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
base_branch: str = "HEAD"
|
|
||||||
|
|
||||||
|
|
||||||
class CheckoutRequest(BaseModel):
|
|
||||||
branch: str
|
|
||||||
|
|
||||||
|
|
||||||
class CommitRequest(BaseModel):
|
|
||||||
message: str
|
|
||||||
files: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CommitResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class FetchResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class PullResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class PushResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class MergeRequest(BaseModel):
|
|
||||||
source_branch: str
|
|
||||||
target_branch: str | None = None
|
|
||||||
message: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MergeResponse(BaseModel):
|
|
||||||
commit_hash: str
|
|
||||||
message: str
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Health check response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHealth(BaseModel):
|
|
||||||
"""Database health check result."""
|
|
||||||
|
|
||||||
status: str = Field(description="Database health status", examples=["healthy"])
|
|
||||||
response_time_ms: float = Field(
|
|
||||||
description="Query response time in milliseconds", examples=[5.2]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DiskHealth(BaseModel):
|
|
||||||
"""Disk space health check result."""
|
|
||||||
|
|
||||||
status: str = Field(description="Disk health status", examples=["healthy"])
|
|
||||||
free_gb: float = Field(description="Free disk space in GB", examples=[45.2])
|
|
||||||
total_gb: float = Field(description="Total disk space in GB", examples=[100.0])
|
|
||||||
|
|
||||||
|
|
||||||
class HealthChecks(BaseModel):
|
|
||||||
"""Individual health checks."""
|
|
||||||
|
|
||||||
database: DatabaseHealth | None = None
|
|
||||||
disk: DiskHealth | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
"""Overall health check response."""
|
|
||||||
|
|
||||||
status: str = Field(description="Overall health status", examples=["healthy"])
|
|
||||||
timestamp: str = Field(
|
|
||||||
description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]
|
|
||||||
)
|
|
||||||
version: str = Field(description="API version", examples=["0.1.0"])
|
|
||||||
checks: HealthChecks = Field(description="Individual health checks")
|
|
||||||
uptime_seconds: float = Field(
|
|
||||||
description="Server uptime in seconds", examples=[3600.0]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHealthResponse(BaseModel):
|
|
||||||
"""Database-specific health check response."""
|
|
||||||
|
|
||||||
status: str = Field(description="Database health status", examples=["healthy"])
|
|
||||||
response_time_ms: float = Field(
|
|
||||||
description="Query response time in milliseconds", examples=[5.2]
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Project request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class SetDefaultSSHKeyRequest(BaseModel):
|
|
||||||
ssh_key_id: str
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""SSH key request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
|
|
||||||
|
|
||||||
class SSHKeyResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
public_key: str
|
|
||||||
fingerprint: str
|
|
||||||
created_at: str
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Tool config request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigCreate(BaseModel):
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type")
|
|
||||||
key: str = Field(description="Configuration key")
|
|
||||||
value: str = Field(description="Configuration value")
|
|
||||||
config_type: str = Field(default="env", description="Config type: env or file")
|
|
||||||
file_path: str | None = Field(default=None, description="File path for file configs")
|
|
||||||
port_override: int | None = Field(default=None, description="Port override")
|
|
||||||
start_command: str | None = Field(default=None, description="Start command override")
|
|
||||||
working_directory: str | None = Field(default=None, description="Working directory")
|
|
||||||
environment_variables: dict[str, str] | None = Field(
|
|
||||||
default=None, description="Additional environment variables"
|
|
||||||
)
|
|
||||||
volumes: list[dict] | None = Field(default=None, description="Volume mounts")
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigUpdate(BaseModel):
|
|
||||||
value: str | None = None
|
|
||||||
config_type: str | None = None
|
|
||||||
file_path: str | None = None
|
|
||||||
port_override: int | None = None
|
|
||||||
start_command: str | None = None
|
|
||||||
working_directory: str | None = None
|
|
||||||
environment_variables: dict[str, str] | None = None
|
|
||||||
volumes: list[dict] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ToolConfigResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
tool_type_id: str
|
|
||||||
user_id: str
|
|
||||||
project_id: str | None
|
|
||||||
key: str
|
|
||||||
value: str
|
|
||||||
config_type: str
|
|
||||||
file_path: str | None
|
|
||||||
port_override: int | None
|
|
||||||
start_command: str | None
|
|
||||||
working_directory: str | None
|
|
||||||
environment_variables: dict[str, str] | None
|
|
||||||
volumes: list[dict] | None
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Tool instance request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CreateInstanceRequest(BaseModel):
|
|
||||||
"""Request body for creating a tool instance."""
|
|
||||||
|
|
||||||
model_config = {"extra": "ignore"}
|
|
||||||
|
|
||||||
tool_type_id: str = Field(description="UUID of the tool type to instantiate")
|
|
||||||
display_name: str | None = Field(
|
|
||||||
default=None, description="Optional display name for the instance"
|
|
||||||
)
|
|
||||||
config_profile_id: str | None = Field(
|
|
||||||
default=None, description="Optional config profile ID to apply to the instance"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionItemResponse(BaseModel):
|
|
||||||
"""Lightweight session summary for sidebar and dashboard."""
|
|
||||||
|
|
||||||
model_config = {"extra": "ignore"}
|
|
||||||
|
|
||||||
id: str = Field(description="Session (tool instance) ID")
|
|
||||||
display_name: str = Field(description="Display name of the session")
|
|
||||||
tool_type_name: str = Field(description="Name of the tool type")
|
|
||||||
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
|
||||||
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
|
||||||
repository_name: str = Field(description="Name of the repository")
|
|
||||||
repository_id: str = Field(description="Repository ID")
|
|
||||||
project_name: str = Field(description="Name of the project")
|
|
||||||
project_id: str = Field(description="Project ID")
|
|
||||||
status: str = Field(description="Current status")
|
|
||||||
url: str | None = Field(default=None, description="Access URL")
|
|
||||||
|
|
||||||
|
|
||||||
class SessionListResponse(BaseModel):
|
|
||||||
"""Response wrapping a list of session summaries."""
|
|
||||||
|
|
||||||
sessions: list[SessionItemResponse]
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
"""Tool type request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int
|
|
||||||
definition_type: str = "compose"
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] = []
|
|
||||||
category: str = "other"
|
|
||||||
interfaces: list[str] = ["web"]
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str) -> str:
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "dockerfile":
|
|
||||||
return v
|
|
||||||
if v is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("default_port")
|
|
||||||
@classmethod
|
|
||||||
def validate_default_port(cls, v: int, info) -> int:
|
|
||||||
if v <= 0 or v > 65535:
|
|
||||||
raise ValueError("Port must be between 1 and 65535")
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(template)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return v
|
|
||||||
port_str = str(v)
|
|
||||||
port_exposed = False
|
|
||||||
if isinstance(parsed, dict) and "services" in parsed:
|
|
||||||
for service_config in parsed["services"].values():
|
|
||||||
if isinstance(service_config, dict) and "ports" in service_config:
|
|
||||||
for port_mapping in service_config["ports"]:
|
|
||||||
if isinstance(port_mapping, str) and port_str in port_mapping:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
elif isinstance(port_mapping, int) and port_mapping == v:
|
|
||||||
port_exposed = True
|
|
||||||
break
|
|
||||||
if port_exposed:
|
|
||||||
break
|
|
||||||
if not port_exposed:
|
|
||||||
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("required_variables")
|
|
||||||
@classmethod
|
|
||||||
def validate_required_variables(cls, v: list[str], info) -> list[str]:
|
|
||||||
if not v:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
if data.get("definition_type") != "compose":
|
|
||||||
return v
|
|
||||||
template = data.get("compose_template")
|
|
||||||
if not template:
|
|
||||||
return v
|
|
||||||
for var in v:
|
|
||||||
placeholder = f"{{{{{var}}}}}"
|
|
||||||
if placeholder not in template:
|
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_templates(self) -> "ToolTypeCreate":
|
|
||||||
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
|
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
|
||||||
if self.definition_type == "compose" and self.compose_template is None:
|
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeUpdate(BaseModel):
|
|
||||||
display_name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
default_port: int | None = None
|
|
||||||
definition_type: str | None = None
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
build_context: dict | None = None
|
|
||||||
readiness_probe: dict | None = None
|
|
||||||
required_variables: list[str] | None = None
|
|
||||||
category: str | None = None
|
|
||||||
interfaces: list[str] | None = None
|
|
||||||
|
|
||||||
@field_validator("definition_type")
|
|
||||||
@classmethod
|
|
||||||
def validate_definition_type(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v not in ("compose", "dockerfile"):
|
|
||||||
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("compose_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_compose_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "compose":
|
|
||||||
return v
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(v)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"Invalid YAML: {e}")
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
raise ValueError("Compose template must be a YAML mapping")
|
|
||||||
if "services" not in parsed:
|
|
||||||
raise ValueError("Compose template must contain 'services' key")
|
|
||||||
if not parsed["services"]:
|
|
||||||
raise ValueError("Compose template must define at least one service")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("dockerfile_template")
|
|
||||||
@classmethod
|
|
||||||
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
data = info.data
|
|
||||||
definition_type = data.get("definition_type")
|
|
||||||
if definition_type and definition_type != "dockerfile":
|
|
||||||
return v
|
|
||||||
if not v.strip().startswith("FROM"):
|
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
display_name: str
|
|
||||||
description: str | None
|
|
||||||
category: str
|
|
||||||
interfaces: list[str]
|
|
||||||
default_port: int
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None
|
|
||||||
dockerfile_template: str | None
|
|
||||||
build_context: dict | None
|
|
||||||
readiness_probe: dict | None
|
|
||||||
required_variables: list[str]
|
|
||||||
is_builtin: bool
|
|
||||||
created_by_id: uuid.UUID | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ToolTypeValidateRequest(BaseModel):
|
|
||||||
definition_type: str
|
|
||||||
compose_template: str | None = None
|
|
||||||
dockerfile_template: str | None = None
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""User request/response schemas."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
email: str
|
|
||||||
name: str
|
|
||||||
avatar_url: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
email: str | None = None
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""User config request/response schemas."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class UserConfigResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
default_editor: str | None = None
|
|
||||||
theme: str = "system"
|
|
||||||
git_user_name: str | None = None
|
|
||||||
git_user_email: str | None = None
|
|
||||||
last_session_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
|
||||||
default_editor: str | None = None
|
|
||||||
theme: str | None = None
|
|
||||||
git_user_name: str | None = None
|
|
||||||
git_user_email: str | None = None
|
|
||||||
last_session_id: str | None = None
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from src.database import SessionLocal
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _table_exists(session, table_name: str) -> bool:
|
|
||||||
"""Check if a table exists in the database."""
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
text(
|
|
||||||
"""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = :table_name
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{"table_name": table_name},
|
|
||||||
)
|
|
||||||
return result.scalar() or False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def seed_builtin_tool_types():
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# Check if tool_types table exists before attempting to seed
|
|
||||||
if not await _table_exists(session, "tool_types"):
|
|
||||||
logger.warning(
|
|
||||||
"tool_types table does not exist. Skipping seeding. "
|
|
||||||
"Migrations may not have run yet."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
builtin_types = [
|
|
||||||
{
|
|
||||||
"name": "code-server",
|
|
||||||
"display_name": "VS Code Server",
|
|
||||||
"description": "VS Code running in the browser via code-server",
|
|
||||||
"category": "editor",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
- TZ=Europe/London
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/config/workspace
|
|
||||||
ports:
|
|
||||||
- "8443:8443"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"default_port": 8443,
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "jupyter-notebook",
|
|
||||||
"display_name": "Jupyter Notebook",
|
|
||||||
"description": "Jupyter Lab for interactive development",
|
|
||||||
"category": "notebook",
|
|
||||||
"interfaces": ["web"],
|
|
||||||
"default_port": 8888,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
jupyter:
|
|
||||||
image: jupyter/scipy-notebook:latest
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
environment:
|
|
||||||
- JUPYTER_ENABLE_LAB=yes
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/home/jovyan/work
|
|
||||||
ports:
|
|
||||||
- "8888:8888"
|
|
||||||
restart: unless-stopped""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "opencode",
|
|
||||||
"display_name": "OpenCode",
|
|
||||||
"description": "AI coding assistant - run opencode in terminal",
|
|
||||||
"category": "ai-assistant",
|
|
||||||
"interfaces": ["terminal"],
|
|
||||||
"default_port": 3000,
|
|
||||||
"compose_template": """version: "3.8"
|
|
||||||
services:
|
|
||||||
opencode:
|
|
||||||
image: node:20-slim
|
|
||||||
container_name: {{TOOL_NAME}}
|
|
||||||
working_dir: /workspace
|
|
||||||
environment:
|
|
||||||
- HOME=/tmp
|
|
||||||
volumes:
|
|
||||||
- {{REPO_PATH}}:/workspace
|
|
||||||
- opencode_home:/tmp
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: >
|
|
||||||
sh -c "set -x &&
|
|
||||||
apt-get update && apt-get install -y git ca-certificates &&
|
|
||||||
echo 'Installing opencode...' &&
|
|
||||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
|
||||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
|
||||||
npm bin -g &&
|
|
||||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
|
||||||
echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc &&
|
|
||||||
echo 'cd /workspace' >> /root/.bashrc &&
|
|
||||||
echo 'OpenCode installation complete' &&
|
|
||||||
cd /workspace &&
|
|
||||||
exec tail -f /dev/null"
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
opencode_home:""",
|
|
||||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for tool_data in builtin_types:
|
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
|
||||||
if not existing:
|
|
||||||
tool_type = ToolType(
|
|
||||||
name=tool_data["name"],
|
|
||||||
display_name=tool_data["display_name"],
|
|
||||||
description=tool_data["description"],
|
|
||||||
category=tool_data["category"],
|
|
||||||
interfaces=tool_data["interfaces"],
|
|
||||||
definition_type="compose",
|
|
||||||
compose_template=tool_data["compose_template"],
|
|
||||||
required_variables=tool_data["required_variables"],
|
|
||||||
default_port=tool_data.get("default_port"),
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
session.add(tool_type)
|
|
||||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
|
||||||
else:
|
|
||||||
# Update existing built-in tool types to reflect code changes
|
|
||||||
existing.display_name = tool_data["display_name"]
|
|
||||||
existing.description = tool_data["description"]
|
|
||||||
existing.category = tool_data["category"]
|
|
||||||
existing.interfaces = tool_data["interfaces"]
|
|
||||||
existing.definition_type = "compose"
|
|
||||||
existing.compose_template = tool_data["compose_template"]
|
|
||||||
existing.required_variables = tool_data["required_variables"]
|
|
||||||
existing.default_port = tool_data.get("default_port")
|
|
||||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info("Built-in tool types seeded successfully.")
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Clone service for repository cloning and dirty state checking."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def clone_repository(
|
||||||
|
remote_url: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
instance_dir: str,
|
||||||
|
branch: str = "main",
|
||||||
|
) -> str:
|
||||||
|
"""Clone a git repository into the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: Git remote URL (SSH or HTTPS)
|
||||||
|
ssh_key_path: Path to SSH private key for authentication (optional)
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
branch: Branch to clone (default: main)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the cloned repository
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
clone_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
if ssh_key_path:
|
||||||
|
# Use SSH key for cloning
|
||||||
|
env["GIT_SSH_COMMAND"] = f"ssh -i {ssh_key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch", branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
str(clone_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug("Cloning repository %s (branch: %s) into %s", remote_url, branch, clone_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Git clone failed: %s", result.stderr)
|
||||||
|
raise RuntimeError(f"Failed to clone repository: {result.stderr}")
|
||||||
|
|
||||||
|
logger.debug("Successfully cloned repository into %s", clone_path)
|
||||||
|
return str(clone_path)
|
||||||
|
|
||||||
|
|
||||||
|
def check_dirty_state(clone_path: str) -> tuple[bool, list[str]]:
|
||||||
|
"""Check for uncommitted changes in a cloned repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clone_path: Path to the cloned repository
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_dirty, list_of_changed_files)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", clone_path, "status", "--short"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning("Failed to check git status: %s", result.stderr)
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
changed_files = [line.strip() for line in result.stdout.split("\n") if line.strip()]
|
||||||
|
is_dirty = len(changed_files) > 0
|
||||||
|
|
||||||
|
return is_dirty, changed_files
|
||||||
|
|
||||||
|
|
||||||
|
def remove_clone_directory(instance_dir: str) -> None:
|
||||||
|
"""Remove the cloned repository from the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
clone_path = Path(instance_dir) / "repo-clone"
|
||||||
|
if clone_path.exists():
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(clone_path)
|
||||||
|
logger.debug("Removed clone directory: %s", clone_path)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""Config profile resolver service.
|
||||||
|
|
||||||
|
Provides recursive ordered include resolution with deterministic merge rules
|
||||||
|
and cycle protection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileCycleError(Exception):
|
||||||
|
"""Raised when a cycle is detected in profile includes."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProfileNotFoundError(Exception):
|
||||||
|
"""Raised when a referenced profile is not found."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedMount:
|
||||||
|
"""A resolved mount with merged files and final mode."""
|
||||||
|
|
||||||
|
target: str
|
||||||
|
mode: str
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
overridden_files: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResolvedProfile:
|
||||||
|
"""The fully resolved output of a config profile."""
|
||||||
|
|
||||||
|
profile_id: uuid.UUID
|
||||||
|
profile_name: str
|
||||||
|
env_vars: dict[str, str] = field(default_factory=dict)
|
||||||
|
runtime_hints: dict[str, Any] = field(default_factory=dict)
|
||||||
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
mount_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
||||||
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: The profile ID to check.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a cycle would be created.
|
||||||
|
"""
|
||||||
|
if profile_id in visited:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_env_vars(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge env vars, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_runtime_hints(
|
||||||
|
base: dict[str, Any],
|
||||||
|
overlay: dict[str, Any],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Merge runtime hints, tracking overrides.
|
||||||
|
|
||||||
|
Later values replace earlier values.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for key, value in overlay.items():
|
||||||
|
if key in result and result[key] != value:
|
||||||
|
overrides[key] = source_name
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_files(
|
||||||
|
base: dict[str, str],
|
||||||
|
overlay: dict[str, str],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Merge file maps, tracking overrides.
|
||||||
|
|
||||||
|
Later relative file paths win.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for path, content in overlay.items():
|
||||||
|
if path in result and result[path] != content:
|
||||||
|
overrides[path] = source_name
|
||||||
|
result[path] = content
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_mounts(
|
||||||
|
base: dict[str, ResolvedMount],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
overrides: dict[str, str],
|
||||||
|
source_name: str,
|
||||||
|
) -> dict[str, ResolvedMount]:
|
||||||
|
"""Merge mounts, tracking overrides.
|
||||||
|
|
||||||
|
Mounts with the same target path have their file maps merged and later
|
||||||
|
relative file paths win. Mode conflicts: later layer wins.
|
||||||
|
"""
|
||||||
|
result = dict(base)
|
||||||
|
for mount_data in overlay:
|
||||||
|
target = mount_data["target"]
|
||||||
|
mode = mount_data.get("mode", "rw")
|
||||||
|
files = mount_data.get("files", {})
|
||||||
|
|
||||||
|
if target in result:
|
||||||
|
existing = result[target]
|
||||||
|
merged_files = dict(existing.files)
|
||||||
|
file_overrides = dict(existing.overridden_files)
|
||||||
|
for rel_path, content in files.items():
|
||||||
|
if rel_path in merged_files and merged_files[rel_path] != content:
|
||||||
|
file_overrides[rel_path] = source_name
|
||||||
|
merged_files[rel_path] = content
|
||||||
|
if existing.mode != mode:
|
||||||
|
overrides[target] = source_name
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=merged_files,
|
||||||
|
overridden_files=file_overrides,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result[target] = ResolvedMount(
|
||||||
|
target=target,
|
||||||
|
mode=mode,
|
||||||
|
files=dict(files),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_git_mounts(
|
||||||
|
base: list[dict[str, Any]],
|
||||||
|
overlay: list[dict[str, Any]],
|
||||||
|
source_name: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
|
Later mounts override earlier ones with the same remote_url + target_path combo.
|
||||||
|
"""
|
||||||
|
result = list(base)
|
||||||
|
# Build lookup by (remote_url, target_path)
|
||||||
|
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
||||||
|
for mount in overlay:
|
||||||
|
key = (mount["remote_url"], mount["target_path"])
|
||||||
|
if key in seen:
|
||||||
|
result[seen[key]] = dict(mount)
|
||||||
|
else:
|
||||||
|
seen[key] = len(result)
|
||||||
|
result.append(dict(mount))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_profile_recursive(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Recursively resolve a profile and its includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
visited: Set of already-visited profile IDs in current resolution chain.
|
||||||
|
path: Current resolution path for error reporting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with all includes merged.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
if _detect_cycle(profile_id, visited, path):
|
||||||
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
|
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
||||||
|
|
||||||
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
|
if profile is None:
|
||||||
|
raise ConfigProfileNotFoundError(f"Config profile not found: {profile_id}")
|
||||||
|
|
||||||
|
new_visited = visited | {profile_id}
|
||||||
|
new_path = path + [profile_id]
|
||||||
|
|
||||||
|
result = ResolvedProfile(
|
||||||
|
profile_id=profile.id,
|
||||||
|
profile_name=profile.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve includes in order
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == profile_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
included = await _resolve_profile_recursive(
|
||||||
|
session, include.included_profile_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
result.included_profiles.append({
|
||||||
|
"id": str(included.profile_id),
|
||||||
|
"name": included.profile_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
included.runtime_hints,
|
||||||
|
result.hint_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files, included.files, result.file_overrides, included.profile_name
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
[
|
||||||
|
{"target": m.target, "mode": m.mode, "files": m.files}
|
||||||
|
for m in included.mounts.values()
|
||||||
|
],
|
||||||
|
result.mount_overrides,
|
||||||
|
included.profile_name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts, included.git_mounts, included.profile_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
|
result.env_vars = _merge_env_vars(
|
||||||
|
result.env_vars,
|
||||||
|
profile.env_vars or {},
|
||||||
|
result.env_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
|
result.runtime_hints,
|
||||||
|
profile.runtime_hints or {},
|
||||||
|
result.hint_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.files = _merge_files(
|
||||||
|
result.files,
|
||||||
|
profile.files or {},
|
||||||
|
result.file_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.mounts = _merge_mounts(
|
||||||
|
result.mounts,
|
||||||
|
profile.mounts or [],
|
||||||
|
result.mount_overrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
result.git_mounts = _merge_git_mounts(
|
||||||
|
result.git_mounts,
|
||||||
|
profile.git_mounts or [],
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_profile(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
) -> ResolvedProfile:
|
||||||
|
"""Resolve a config profile with all includes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: Profile ID to resolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ResolvedProfile with merged env vars, runtime hints, mounts, and files.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigProfileCycleError: If a cycle is detected in includes.
|
||||||
|
ConfigProfileNotFoundError: If the profile is not found.
|
||||||
|
"""
|
||||||
|
return await _resolve_profile_recursive(session, profile_id, set(), [])
|
||||||
|
|
||||||
|
|
||||||
|
async def check_include_cycle(
|
||||||
|
session: AsyncSession,
|
||||||
|
profile_id: uuid.UUID,
|
||||||
|
new_include_id: uuid.UUID | None = None,
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
"""Check if adding an include would create a cycle.
|
||||||
|
|
||||||
|
Used at save time to validate include relationships before persisting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
profile_id: The profile that would receive the new include.
|
||||||
|
new_include_id: Optional new profile to include. If None, checks existing includes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The cycle path as a list of UUIDs if a cycle exists, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _check_from(
|
||||||
|
current_id: uuid.UUID,
|
||||||
|
target_id: uuid.UUID,
|
||||||
|
visited: set[uuid.UUID],
|
||||||
|
path: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
if current_id in visited:
|
||||||
|
if current_id == target_id:
|
||||||
|
return path + [current_id]
|
||||||
|
return None
|
||||||
|
if current_id == target_id and path:
|
||||||
|
return path + [current_id]
|
||||||
|
|
||||||
|
new_visited = visited | {current_id}
|
||||||
|
new_path = path + [current_id]
|
||||||
|
|
||||||
|
include_query = (
|
||||||
|
select(ConfigProfileInclude)
|
||||||
|
.where(ConfigProfileInclude.profile_id == current_id)
|
||||||
|
.order_by(ConfigProfileInclude.order_index)
|
||||||
|
)
|
||||||
|
include_result = await session.execute(include_query)
|
||||||
|
includes = include_result.scalars().all()
|
||||||
|
|
||||||
|
for include in includes:
|
||||||
|
cycle = await _check_from(
|
||||||
|
include.included_profile_id, target_id, new_visited, new_path
|
||||||
|
)
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if new_include_id can reach profile_id (would create cycle)
|
||||||
|
if new_include_id is not None:
|
||||||
|
cycle = await _check_from(new_include_id, profile_id, set(), [])
|
||||||
|
if cycle is not None:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
# Also check existing includes for cycles
|
||||||
|
cycle = await _check_from(profile_id, profile_id, set(), [])
|
||||||
|
if cycle is not None and len(cycle) > 1:
|
||||||
|
return cycle
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_resolved_profile(
|
||||||
|
instance_dir: str,
|
||||||
|
resolved: ResolvedProfile,
|
||||||
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
|
Stages files, writes env vars, and prepares mount volumes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to the instance directory.
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (env_vars, files, volume_mounts, runtime_hints).
|
||||||
|
env_vars: Merged environment variables.
|
||||||
|
files: Relative file paths to content for the instance.
|
||||||
|
volume_mounts: List of Docker volume mount dicts.
|
||||||
|
runtime_hints: Extracted runtime hints.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
env_vars = dict(resolved.env_vars)
|
||||||
|
files = dict(resolved.files)
|
||||||
|
volume_mounts = []
|
||||||
|
|
||||||
|
# Write profile files to instance directory
|
||||||
|
for file_path, content in files.items():
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
# Stage mount files and prepare volume mounts
|
||||||
|
for mount in resolved.mounts.values():
|
||||||
|
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
||||||
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for file_path, content in mount.files.items():
|
||||||
|
full_path = mount_dir / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(mount_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Mount file path escapes mount directory: %s", file_path)
|
||||||
|
continue
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
volume_mounts.append({
|
||||||
|
"source": str(mount_dir),
|
||||||
|
"target": mount.target,
|
||||||
|
"type": "bind",
|
||||||
|
})
|
||||||
|
|
||||||
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resolved: The resolved profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with env_vars, runtime_hints, mounts, files, and metadata.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"profile_id": str(resolved.profile_id),
|
||||||
|
"profile_name": resolved.profile_name,
|
||||||
|
"env_vars": resolved.env_vars,
|
||||||
|
"runtime_hints": resolved.runtime_hints,
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"target": m.target,
|
||||||
|
"mode": m.mode,
|
||||||
|
"files": m.files,
|
||||||
|
"overridden_files": m.overridden_files,
|
||||||
|
}
|
||||||
|
for m in resolved.mounts.values()
|
||||||
|
],
|
||||||
|
"files": resolved.files,
|
||||||
|
"overrides": {
|
||||||
|
"env_vars": resolved.env_overrides,
|
||||||
|
"runtime_hints": resolved.hint_overrides,
|
||||||
|
"files": resolved.file_overrides,
|
||||||
|
"mounts": resolved.mount_overrides,
|
||||||
|
},
|
||||||
|
"git_mounts": resolved.git_mounts,
|
||||||
|
"included_profiles": resolved.included_profiles,
|
||||||
|
}
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""Config profile business logic."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user_config import UserConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAX_INCLUDES_DEPTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
async def get_owned_profile(
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> ConfigProfile:
|
|
||||||
"""Fetch a config profile and verify ownership."""
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
|
||||||
if profile is None or profile.user_id != user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="config profile not found",
|
|
||||||
)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
async def _detect_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
visited: set[uuid.UUID] | None = None,
|
|
||||||
depth: int = 0,
|
|
||||||
) -> bool:
|
|
||||||
"""Detect cycles in profile includes using DFS.
|
|
||||||
|
|
||||||
Returns True if a cycle is detected.
|
|
||||||
"""
|
|
||||||
if depth > MAX_INCLUDES_DEPTH:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
|
|
||||||
if profile_id in visited:
|
|
||||||
return True
|
|
||||||
|
|
||||||
visited.add(profile_id)
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude.included_profile_id).where(
|
|
||||||
ConfigInclude.profile_id == profile_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
included_ids = result.scalars().all()
|
|
||||||
|
|
||||||
for included_id in included_ids:
|
|
||||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_includes_no_cycle(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
new_included_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Validate that adding an include wouldn't create a cycle."""
|
|
||||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="adding this include would create a circular reference",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Profile CRUD helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_name(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
name: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a profile with the given name already exists."""
|
|
||||||
query = select(ConfigProfile).where(
|
|
||||||
ConfigProfile.user_id == user_id,
|
|
||||||
ConfigProfile.name == name,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigProfile.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"config profile with name '{name}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_to_dict(profile: ConfigProfile) -> dict:
|
|
||||||
"""Serialize a ConfigProfile to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(profile.id),
|
|
||||||
"user_id": str(profile.user_id),
|
|
||||||
"name": profile.name,
|
|
||||||
"description": profile.description,
|
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_include(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
included_profile_id: uuid.UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if the include already exists."""
|
|
||||||
existing = await session.scalar(
|
|
||||||
select(ConfigInclude).where(
|
|
||||||
ConfigInclude.profile_id == profile_id,
|
|
||||||
ConfigInclude.included_profile_id == included_profile_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="this include already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict:
|
|
||||||
"""Serialize a ConfigInclude to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(inc.id),
|
|
||||||
"profile_id": str(inc.profile_id),
|
|
||||||
"included_profile_id": str(inc.included_profile_id),
|
|
||||||
"included_profile_name": included_name,
|
|
||||||
"order_index": inc.order_index,
|
|
||||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
|
||||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def check_duplicate_mount_path(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
target_path: str,
|
|
||||||
exclude_id: uuid.UUID | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Raise 409 if a mount with the given path already exists."""
|
|
||||||
query = select(ConfigMount).where(
|
|
||||||
ConfigMount.profile_id == profile_id,
|
|
||||||
ConfigMount.target_path == target_path,
|
|
||||||
)
|
|
||||||
if exclude_id:
|
|
||||||
query = query.where(ConfigMount.id != exclude_id)
|
|
||||||
existing = await session.scalar(query)
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"mount with path '{target_path}' already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mount_to_dict(mount: ConfigMount) -> dict:
|
|
||||||
"""Serialize a ConfigMount to a dict."""
|
|
||||||
return {
|
|
||||||
"id": str(mount.id),
|
|
||||||
"profile_id": str(mount.profile_id),
|
|
||||||
"target_path": mount.target_path,
|
|
||||||
"files": mount.files,
|
|
||||||
"mode": mount.mode,
|
|
||||||
"order_index": mount.order_index,
|
|
||||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
|
||||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Default profile helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_or_create_user_config(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> UserConfig:
|
|
||||||
"""Get existing user config or create a new one."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
if user_config is None:
|
|
||||||
user_config = UserConfig(user_id=user_id, config={})
|
|
||||||
session.add(user_config)
|
|
||||||
return user_config
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Validate that all profile IDs in default_profiles belong to the user."""
|
|
||||||
for tool_type_id, profile_id_str in default_profiles.items():
|
|
||||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
|
||||||
if profile is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found")
|
|
||||||
if profile.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profiles for a user."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
return {"default_profiles": user_config.default_profiles if user_config else {}}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_default_profiles(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
default_profiles: dict[str, str],
|
|
||||||
) -> dict:
|
|
||||||
"""Set default profiles for a user."""
|
|
||||||
user_config = await get_or_create_user_config(session, user_id)
|
|
||||||
await validate_default_profiles(session, user_id, default_profiles)
|
|
||||||
user_config.config = {**user_config.config, "default_profiles": default_profiles}
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(user_config)
|
|
||||||
return {"default_profiles": user_config.default_profiles}
|
|
||||||
|
|
||||||
|
|
||||||
async def get_default_profile_for_tool_type(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
tool_type_id: str,
|
|
||||||
) -> dict:
|
|
||||||
"""Get default profile for a specific tool type."""
|
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
|
||||||
user_config = result.scalar_one_or_none()
|
|
||||||
profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None
|
|
||||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Include list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_includes_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all includes for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigInclude)
|
|
||||||
.where(ConfigInclude.profile_id == profile_id)
|
|
||||||
.order_by(ConfigInclude.order_index)
|
|
||||||
)
|
|
||||||
includes_data = []
|
|
||||||
for inc in result.scalars().all():
|
|
||||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
|
||||||
includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None))
|
|
||||||
return {"includes": includes_data}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Mount list helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_mounts_for_profile(
|
|
||||||
session: AsyncSession,
|
|
||||||
profile_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
"""List all mounts for a profile."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(ConfigMount)
|
|
||||||
.where(ConfigMount.profile_id == profile_id)
|
|
||||||
.order_by(ConfigMount.order_index)
|
|
||||||
)
|
|
||||||
return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]}
|
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
|
"""Render a Docker Compose template with variable substitution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: The compose template string
|
||||||
|
variables: Dictionary of variable names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered compose file content
|
||||||
|
"""
|
||||||
|
result = template
|
||||||
|
for key, value in variables.items():
|
||||||
|
placeholder = f"{{{{{key}}}}}"
|
||||||
|
result = result.replace(placeholder, str(value))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||||
|
"""Create and return the instance directory path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_id: Unique instance identifier
|
||||||
|
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to instance directory
|
||||||
|
"""
|
||||||
|
if base_path is None:
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
base_path = Settings().instance_base_path
|
||||||
|
instance_dir = Path(base_path) / instance_id
|
||||||
|
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(instance_dir.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||||
|
"""Write the rendered compose file to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
content: Rendered compose content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the compose file
|
||||||
|
"""
|
||||||
|
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||||
|
compose_path.write_text(content)
|
||||||
|
return str(compose_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||||
|
"""Write environment variables to a .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
env_vars: Dictionary of env var names to values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the env file
|
||||||
|
"""
|
||||||
|
env_path = Path(instance_dir) / ".env"
|
||||||
|
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
return str(env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||||
|
"""Write config files to the instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
files: Dictionary of file paths (relative to instance dir) to content
|
||||||
|
"""
|
||||||
|
instance_path = Path(instance_dir)
|
||||||
|
for file_path, content in files.items():
|
||||||
|
# Ensure the path is within the instance directory (security)
|
||||||
|
full_path = instance_path / file_path
|
||||||
|
try:
|
||||||
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||||
|
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_compose_command(
|
||||||
|
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""Execute a docker compose command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
compose_path: Path to docker-compose.yml
|
||||||
|
action: The compose action (up, down, start, stop, restart)
|
||||||
|
timeout: Command timeout in seconds
|
||||||
|
env_file: Optional path to .env file for environment variables
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (returncode, stdout, stderr)
|
||||||
|
"""
|
||||||
|
instance_dir = Path(compose_path).parent
|
||||||
|
|
||||||
|
cmd = ["docker", "compose", "-f", compose_path]
|
||||||
|
|
||||||
|
if env_file:
|
||||||
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
|
if action == "up":
|
||||||
|
cmd.extend(["up", "-d"])
|
||||||
|
elif action == "down":
|
||||||
|
cmd.extend(["down", "-v"])
|
||||||
|
elif action in ("start", "stop", "restart"):
|
||||||
|
cmd.append(action)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown compose action: {action}")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(instance_dir),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container ID or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
|
Searches all containers including stopped/exited ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The service name in compose
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container name or None if not found
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
"--filter",
|
||||||
|
f"name={instance_name}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().split("\n")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def connect_container_to_network(
|
||||||
|
container_name: str, network_name: str = "backend"
|
||||||
|
) -> bool:
|
||||||
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name or ID of the container
|
||||||
|
network_name: Name of the Docker network (default: backend)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "network", "connect", network_name, container_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'status' (running, exited, restarting, not_found),
|
||||||
|
'exit_code' (int or None), and 'health' (health status or None)
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {"status": "not_found", "exit_code": None, "health": None}
|
||||||
|
|
||||||
|
parts = result.stdout.strip().split("|")
|
||||||
|
status = parts[0] if parts else "unknown"
|
||||||
|
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||||
|
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||||
|
|
||||||
|
return {"status": status, "exit_code": exit_code, "health": health}
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_container_running(
|
||||||
|
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for a container to reach the running state.
|
||||||
|
|
||||||
|
Polls docker inspect until the container status is "running" or timeout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
timeout: Maximum seconds to wait
|
||||||
|
interval: Seconds between polls
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||||
|
and 'waited_seconds' (float)
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
|
||||||
|
if info["status"] == "running":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "exited":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "exited",
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if info["status"] == "not_found":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": "not_found",
|
||||||
|
"exit_code": None,
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
info = get_container_status(container_id)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"status": info["status"],
|
||||||
|
"exit_code": info["exit_code"],
|
||||||
|
"waited_seconds": time.time() - start_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||||
|
"""Get the logs of a Docker container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID
|
||||||
|
tail: Number of lines to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Container logs
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", "--tail", str(tail), container_id],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout
|
||||||
|
return f"Failed to get logs: {result.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||||
|
"""Find a free TCP port in the given range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start: Start of port range
|
||||||
|
end: End of port range
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Free port number
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
for port in range(start, end):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(("localhost", port)) != 0:
|
||||||
|
return port
|
||||||
|
|
||||||
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_cloudflared_tunnel(
|
||||||
|
container_name: str, port: int, timeout: int = 30
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for a container.
|
||||||
|
|
||||||
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
||||||
|
with a random trycloudflare.com URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
timeout: Maximum seconds to wait for tunnel URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# First verify the container is accessible
|
||||||
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
|
for attempt in range(10):
|
||||||
|
check = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
||||||
|
)
|
||||||
|
if check.returncode == 0:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Container %s:%d not responding to curl checks", container_name, port
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run cloudflared in background, capture output
|
||||||
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the URL to appear in output
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = time.time()
|
||||||
|
url = None
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
# Read available output
|
||||||
|
import select
|
||||||
|
|
||||||
|
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
||||||
|
if readable:
|
||||||
|
line = proc.stdout.readline()
|
||||||
|
if line:
|
||||||
|
match = url_pattern.search(line)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to get tunnel URL within {timeout}s. "
|
||||||
|
f"cloudflared output may contain errors."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"url": url, "pid": str(proc.pid)}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
||||||
|
"""Stop a cloudflared tunnel process.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid: Process ID of the cloudflared tunnel
|
||||||
|
"""
|
||||||
|
import signal
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass # Already stopped
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
container_name: str, port: int, old_pid: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a temporary Cloudflare tunnel.
|
||||||
|
|
||||||
|
Stops the old tunnel (if pid provided) and starts a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_name: Name of the Docker container to tunnel to
|
||||||
|
port: Port number the container listens on
|
||||||
|
old_pid: Optional PID of the old tunnel process to stop
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'pid' for the new tunnel
|
||||||
|
"""
|
||||||
|
if old_pid:
|
||||||
|
stop_cloudflared_tunnel(old_pid)
|
||||||
|
|
||||||
|
return start_cloudflared_tunnel(container_name, port)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy with smart error classification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The tunnel URL to check
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
||||||
|
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
elif status_code in (502, 503, 504):
|
||||||
|
# Application error, not tunnel error
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"HTTP {status_code}",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
# Classify connection errors
|
||||||
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {e}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Docker services for container and tunnel management."""
|
|
||||||
|
|
||||||
from .compose import (
|
|
||||||
ensure_instance_directory,
|
|
||||||
execute_compose_command,
|
|
||||||
render_compose_template,
|
|
||||||
write_compose_file,
|
|
||||||
write_env_file,
|
|
||||||
)
|
|
||||||
from .config_staging import write_config_files, write_config_folder_files
|
|
||||||
from .container import (
|
|
||||||
connect_container_to_network,
|
|
||||||
find_free_port,
|
|
||||||
get_container_id,
|
|
||||||
get_container_logs,
|
|
||||||
get_container_name,
|
|
||||||
get_container_status,
|
|
||||||
)
|
|
||||||
from .tunnel import (
|
|
||||||
check_tunnel_health,
|
|
||||||
recreate_tunnel,
|
|
||||||
start_cloudflared_tunnel,
|
|
||||||
stop_cloudflared_tunnel,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"render_compose_template",
|
|
||||||
"ensure_instance_directory",
|
|
||||||
"write_compose_file",
|
|
||||||
"write_env_file",
|
|
||||||
"execute_compose_command",
|
|
||||||
"write_config_files",
|
|
||||||
"write_config_folder_files",
|
|
||||||
"get_container_id",
|
|
||||||
"get_container_name",
|
|
||||||
"connect_container_to_network",
|
|
||||||
"get_container_status",
|
|
||||||
"get_container_logs",
|
|
||||||
"find_free_port",
|
|
||||||
"start_cloudflared_tunnel",
|
|
||||||
"stop_cloudflared_tunnel",
|
|
||||||
"recreate_tunnel",
|
|
||||||
"check_tunnel_health",
|
|
||||||
]
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""Docker Compose file generation and command execution."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.services.profile_resolver import resolve_profile
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
|
||||||
"""Sanitize a string for use in Docker/container names."""
|
|
||||||
sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower())
|
|
||||||
sanitized = re.sub(r"-+", "-", sanitized)
|
|
||||||
return sanitized.strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_instance_name(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_name: str,
|
|
||||||
tool_type_name: str,
|
|
||||||
) -> str:
|
|
||||||
"""Generate a unique instance name: project-tool-NUM."""
|
|
||||||
base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}"
|
|
||||||
base = base.strip("-") or "instance"
|
|
||||||
result = await session.execute(
|
|
||||||
select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%"))
|
|
||||||
)
|
|
||||||
names = result.scalars().all()
|
|
||||||
max_num = 0
|
|
||||||
for name in names:
|
|
||||||
parts = name.rsplit("-", 1)
|
|
||||||
if len(parts) == 2 and parts[0] == base and parts[1].isdigit():
|
|
||||||
max_num = max(max_num, int(parts[1]))
|
|
||||||
return f"{base}-{max_num + 1:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _modify_compose_file(
|
|
||||||
compose_path: str,
|
|
||||||
port_override: int | None = None,
|
|
||||||
start_command: str | None = None,
|
|
||||||
working_directory: str | None = None,
|
|
||||||
extra_volumes: list[dict] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Modify compose file with runtime overrides."""
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
compose_file = Path(compose_path)
|
|
||||||
content = compose_file.read_text()
|
|
||||||
compose_data = yaml.safe_load(content)
|
|
||||||
|
|
||||||
if not compose_data or "services" not in compose_data:
|
|
||||||
return
|
|
||||||
|
|
||||||
for service_name, service_config in compose_data["services"].items():
|
|
||||||
if port_override and "ports" in service_config:
|
|
||||||
for i, port_mapping in enumerate(service_config["ports"]):
|
|
||||||
if isinstance(port_mapping, str) and ":" in port_mapping:
|
|
||||||
_host_port, container_port = port_mapping.split(":", 1)
|
|
||||||
service_config["ports"][i] = f"{port_override}:{container_port}"
|
|
||||||
break
|
|
||||||
|
|
||||||
if start_command:
|
|
||||||
service_config["command"] = start_command
|
|
||||||
|
|
||||||
if working_directory:
|
|
||||||
service_config["working_dir"] = working_directory
|
|
||||||
|
|
||||||
if extra_volumes:
|
|
||||||
if "volumes" not in service_config:
|
|
||||||
service_config["volumes"] = []
|
|
||||||
for vol in extra_volumes:
|
|
||||||
source = vol.get("source", "")
|
|
||||||
target = vol.get("target", "")
|
|
||||||
vol_type = vol.get("type", "bind")
|
|
||||||
if vol_type == "bind":
|
|
||||||
service_config["volumes"].append(f"{source}:{target}")
|
|
||||||
else:
|
|
||||||
service_config["volumes"].append(f"{source}:{target}:{vol_type}")
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_resolved_profile(
|
|
||||||
profile: ConfigProfile,
|
|
||||||
instance_dir: str,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
port_override: int | None,
|
|
||||||
start_command: str | None,
|
|
||||||
working_directory: str | None,
|
|
||||||
extra_volumes: list[dict],
|
|
||||||
) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]:
|
|
||||||
"""Resolve a profile and apply its output to instance configuration."""
|
|
||||||
resolved = resolve_profile(profile)
|
|
||||||
|
|
||||||
if resolved.environment_variables:
|
|
||||||
env_vars.update(resolved.environment_variables)
|
|
||||||
|
|
||||||
if resolved.runtime_hints.start_command is not None:
|
|
||||||
start_command = resolved.runtime_hints.start_command
|
|
||||||
if resolved.runtime_hints.working_directory is not None:
|
|
||||||
working_directory = resolved.runtime_hints.working_directory
|
|
||||||
if resolved.runtime_hints.port is not None:
|
|
||||||
port_override = resolved.runtime_hints.port
|
|
||||||
|
|
||||||
for target_path, mount in resolved.mounts.items():
|
|
||||||
safe_name = target_path.strip("/").replace("/", "_")
|
|
||||||
mount_dir = Path(instance_dir) / "mounts" / safe_name
|
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for rel_path, content in mount.files.items():
|
|
||||||
file_path = mount_dir / rel_path
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path.write_text(content)
|
|
||||||
|
|
||||||
extra_volumes.append({
|
|
||||||
"source": str(mount_dir),
|
|
||||||
"target": target_path,
|
|
||||||
"type": mount.mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
return env_vars, port_override, start_command, working_directory, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
|
||||||
"""Render a Docker Compose template with variable substitution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template: The compose template string
|
|
||||||
variables: Dictionary of variable names to values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Rendered compose file content
|
|
||||||
"""
|
|
||||||
result = template
|
|
||||||
for key, value in variables.items():
|
|
||||||
placeholder = f"{{{{{key}}}}}"
|
|
||||||
result = result.replace(placeholder, str(value))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
|
||||||
"""Create and return the instance directory path.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_id: Unique instance identifier
|
|
||||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Absolute path to instance directory
|
|
||||||
"""
|
|
||||||
if base_path is None:
|
|
||||||
from src.config import Settings
|
|
||||||
base_path = Settings().instance_base_path
|
|
||||||
instance_dir = Path(base_path) / instance_id
|
|
||||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return str(instance_dir.absolute())
|
|
||||||
|
|
||||||
|
|
||||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
|
||||||
"""Write the rendered compose file to the instance directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
content: Rendered compose content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the compose file
|
|
||||||
"""
|
|
||||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
|
||||||
compose_path.write_text(content)
|
|
||||||
return str(compose_path)
|
|
||||||
|
|
||||||
|
|
||||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
|
||||||
"""Write environment variables to a .env file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
env_vars: Dictionary of env var names to values
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the env file
|
|
||||||
"""
|
|
||||||
env_path = Path(instance_dir) / ".env"
|
|
||||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
|
||||||
env_path.write_text("\n".join(lines) + "\n")
|
|
||||||
return str(env_path)
|
|
||||||
|
|
||||||
|
|
||||||
def execute_compose_command(
|
|
||||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
|
||||||
) -> tuple[int, str, str]:
|
|
||||||
"""Execute a docker compose command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
compose_path: Path to docker-compose.yml
|
|
||||||
action: The compose action (up, down, start, stop, restart)
|
|
||||||
timeout: Command timeout in seconds
|
|
||||||
env_file: Optional path to .env file for environment variables
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (returncode, stdout, stderr)
|
|
||||||
"""
|
|
||||||
instance_dir = Path(compose_path).parent
|
|
||||||
|
|
||||||
cmd = ["docker", "compose", "-f", compose_path]
|
|
||||||
|
|
||||||
if env_file:
|
|
||||||
cmd.extend(["--env-file", env_file])
|
|
||||||
|
|
||||||
if action == "up":
|
|
||||||
cmd.extend(["up", "-d"])
|
|
||||||
elif action == "down":
|
|
||||||
cmd.extend(["down", "-v"])
|
|
||||||
elif action in ("start", "stop", "restart"):
|
|
||||||
cmd.append(action)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown compose action: {action}")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
cwd=str(instance_dir),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Config folder file staging for Docker instances."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
|
||||||
"""Write config files to the instance directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
files: Dictionary of file paths (relative to instance dir) to content
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Ensure the path is within the instance directory (security)
|
|
||||||
full_path = instance_path / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]:
|
|
||||||
"""Write config folder files to the instance directory and return volume mounts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_dir: Path to instance directory
|
|
||||||
folders: List of ConfigFolder objects
|
|
||||||
project_id: Optional project ID for applying overrides
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}]
|
|
||||||
"""
|
|
||||||
instance_path = Path(instance_dir)
|
|
||||||
volume_mounts = []
|
|
||||||
|
|
||||||
for folder in folders:
|
|
||||||
# Determine mount path (with project override if applicable)
|
|
||||||
mount_path = folder.mount_path
|
|
||||||
files = folder.files.copy()
|
|
||||||
|
|
||||||
if project_id and folder.project_overrides:
|
|
||||||
override = folder.project_overrides.get(str(project_id))
|
|
||||||
if override:
|
|
||||||
if override.get("mount_path"):
|
|
||||||
mount_path = override["mount_path"]
|
|
||||||
if override.get("files"):
|
|
||||||
files.update(override["files"])
|
|
||||||
|
|
||||||
# Write files to instance directory
|
|
||||||
folder_dir = instance_path / "volumes" / folder.name
|
|
||||||
folder_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file_path, content in files.items():
|
|
||||||
# Security: ensure path doesn't escape folder_dir
|
|
||||||
full_path = folder_dir / file_path
|
|
||||||
try:
|
|
||||||
full_path.resolve().relative_to(folder_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
logger.warning("Config folder file path escapes directory: %s", file_path)
|
|
||||||
continue
|
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
full_path.write_text(content)
|
|
||||||
|
|
||||||
# Add volume mount
|
|
||||||
volume_mounts.append({
|
|
||||||
"source": str(folder_dir),
|
|
||||||
"target": mount_path,
|
|
||||||
"type": "bind",
|
|
||||||
})
|
|
||||||
|
|
||||||
return volume_mounts
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Docker container lifecycle and query operations."""
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_id(instance_name: str) -> str | None:
|
|
||||||
"""Get the container ID for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container ID or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "-q", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_name(instance_name: str) -> str | None:
|
|
||||||
"""Get the full container name for a compose service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance_name: The service name in compose
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container name or None if not found
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip().split("\n")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool:
|
|
||||||
"""Connect a Docker container to an existing network.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name or ID of the container
|
|
||||||
network_name: Name of the Docker network (default: backend)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "network", "connect", network_name, container_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> str:
|
|
||||||
"""Get the status of a Docker container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container status string (running, exited, etc.)
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "inspect", "-f", "{{.State.Status}}", container_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout.strip()
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
|
||||||
"""Get the logs of a Docker container.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_id: Docker container ID
|
|
||||||
tail: Number of lines to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Container logs
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "logs", "--tail", str(tail), container_id],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return result.stdout
|
|
||||||
return f"Failed to get logs: {result.stderr}"
|
|
||||||
|
|
||||||
|
|
||||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|
||||||
"""Find a free TCP port in the given range.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
start: Start of port range
|
|
||||||
end: End of port range
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Free port number
|
|
||||||
"""
|
|
||||||
for port in range(start, end):
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
if s.connect_ex(("localhost", port)) != 0:
|
|
||||||
return port
|
|
||||||
|
|
||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Cloudflare tunnel management for Docker instances."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
|
||||||
container_name: str, port: int, timeout: int = 30
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Start a temporary Cloudflare tunnel for a container.
|
|
||||||
|
|
||||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
|
||||||
with a random trycloudflare.com URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
timeout: Maximum seconds to wait for tunnel URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
|
||||||
"""
|
|
||||||
import select as sel
|
|
||||||
|
|
||||||
# First verify the container is accessible
|
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
|
||||||
for attempt in range(10):
|
|
||||||
check = subprocess.run(
|
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
f"http://{container_name}:{port}"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
|
||||||
if check.returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for the URL to appear in output
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = time.time()
|
|
||||||
url = None
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
# Read available output
|
|
||||||
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
|
||||||
if readable:
|
|
||||||
line = proc.stdout.readline()
|
|
||||||
if line:
|
|
||||||
match = url_pattern.search(line)
|
|
||||||
if match:
|
|
||||||
url = match.group(0)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to get tunnel URL within {timeout}s. "
|
|
||||||
f"cloudflared output may contain errors."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"url": url, "pid": str(proc.pid)}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
|
||||||
"""Stop a cloudflared tunnel process.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pid: Process ID of the cloudflared tunnel
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
os.kill(int(pid), signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass # Already stopped
|
|
||||||
|
|
||||||
|
|
||||||
def recreate_tunnel(
|
|
||||||
container_name: str, port: int, old_pid: str | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Recreate a temporary Cloudflare tunnel.
|
|
||||||
|
|
||||||
Stops the old tunnel (if pid provided) and starts a new one.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
old_pid: Optional PID of the old tunnel process to stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' and 'pid' for the new tunnel
|
|
||||||
"""
|
|
||||||
if old_pid:
|
|
||||||
stop_cloudflared_tunnel(old_pid)
|
|
||||||
|
|
||||||
return start_cloudflared_tunnel(container_name, port)
|
|
||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
||||||
"""Check if a tunnel URL is healthy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The tunnel URL to check
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
||||||
"--max-time", str(timeout), url],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout + 5,
|
|
||||||
)
|
|
||||||
status_code = int(result.stdout.strip())
|
|
||||||
return {
|
|
||||||
"healthy": 200 <= status_code < 400,
|
|
||||||
"status_code": status_code,
|
|
||||||
}
|
|
||||||
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
||||||
return {
|
|
||||||
"healthy": False,
|
|
||||||
"status_code": None,
|
|
||||||
"error": str(e),
|
|
||||||
}
|
|
||||||
@@ -18,13 +18,12 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (returncode, stdout, stderr)
|
Tuple of (returncode, stdout, stderr)
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Write Dockerfile
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile)
|
||||||
logger.info("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -39,10 +38,10 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
logger.info("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.info("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker", "build",
|
||||||
"-t", tag,
|
"-t", tag,
|
||||||
@@ -57,7 +56,7 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=300, # 5 minute timeout for builds
|
timeout=300, # 5 minute timeout for builds
|
||||||
)
|
)
|
||||||
logger.info("Docker build completed: returncode=%d", result.returncode)
|
logger.debug("Docker build completed: returncode=%d", result.returncode)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Docker build failed: %s", result.stderr[:1000])
|
logger.error("Docker build failed: %s", result.stderr[:1000])
|
||||||
return result.returncode, result.stdout, result.stderr
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Git services package."""
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Git control operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
BranchCreateRequest,
|
|
||||||
CheckoutRequest,
|
|
||||||
CommitRequest,
|
|
||||||
FetchResponse,
|
|
||||||
MergeRequest,
|
|
||||||
MergeResponse,
|
|
||||||
PullResponse,
|
|
||||||
PushResponse,
|
|
||||||
StatusResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_control import (
|
|
||||||
checkout_branch,
|
|
||||||
commit_changes,
|
|
||||||
create_branch,
|
|
||||||
delete_branch,
|
|
||||||
fetch,
|
|
||||||
get_status,
|
|
||||||
merge,
|
|
||||||
pull,
|
|
||||||
push,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_status_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> StatusResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
result = get_status(repo.path)
|
|
||||||
return StatusResponse(
|
|
||||||
branch=result.branch,
|
|
||||||
modified=result.modified,
|
|
||||||
added=result.added,
|
|
||||||
deleted=result.deleted,
|
|
||||||
untracked=result.untracked,
|
|
||||||
renamed=result.renamed,
|
|
||||||
ahead=result.ahead,
|
|
||||||
behind=result.behind,
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def create_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: BranchCreateRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
create_branch(repo.path, data.name, data.base_branch)
|
|
||||||
return {"message": f"Branch '{data.name}' created", "branch": data.name}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch_name: str,
|
|
||||||
force: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
delete_branch(repo.path, branch_name, force)
|
|
||||||
return {"message": f"Branch '{branch_name}' deleted"}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def checkout_branch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CheckoutRequest,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
checkout_branch(repo.path, data.branch)
|
|
||||||
return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_changes_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: CommitRequest,
|
|
||||||
user: User,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_changes(
|
|
||||||
repo_path=repo.path,
|
|
||||||
message=data.message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=author_email,
|
|
||||||
files=data.files,
|
|
||||||
)
|
|
||||||
return {"commit_hash": commit_hash, "message": data.message}
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> FetchResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
fetch(repo.path)
|
|
||||||
return FetchResponse(message="Fetched from remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def pull_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PullResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
pull(repo.path, branch)
|
|
||||||
return PullResponse(message="Pulled from remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def push_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str | None = None,
|
|
||||||
) -> PushResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
push(repo.path, branch)
|
|
||||||
return PushResponse(message="Pushed to remote")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def merge_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: MergeRequest,
|
|
||||||
) -> MergeResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
commit_hash = merge(
|
|
||||||
repo_path=repo.path,
|
|
||||||
source_branch=data.source_branch,
|
|
||||||
target_branch=data.target_branch,
|
|
||||||
message=data.message,
|
|
||||||
)
|
|
||||||
return MergeResponse(
|
|
||||||
commit_hash=commit_hash,
|
|
||||||
message=data.message or f"Merge {data.source_branch}",
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Git file operations with repo validation."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import (
|
|
||||||
FileContentResponse,
|
|
||||||
FileListResponse,
|
|
||||||
FileUpdateRequest,
|
|
||||||
FileUpdateResponse,
|
|
||||||
)
|
|
||||||
from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate
|
|
||||||
from src.utils.git_files import (
|
|
||||||
commit_file,
|
|
||||||
get_file_content,
|
|
||||||
list_branches,
|
|
||||||
list_tree,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_files(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str = "main",
|
|
||||||
path: str = "",
|
|
||||||
) -> FileListResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
entries = list_tree(repo.path, branch=branch, path=path)
|
|
||||||
return FileListResponse(
|
|
||||||
path=path,
|
|
||||||
branch=branch,
|
|
||||||
entries=[
|
|
||||||
{
|
|
||||||
"name": e.name,
|
|
||||||
"type": e.type,
|
|
||||||
"path": e.path,
|
|
||||||
"size": e.size,
|
|
||||||
"mode": e.mode,
|
|
||||||
"last_commit": e.last_commit,
|
|
||||||
}
|
|
||||||
for e in entries
|
|
||||||
],
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to list files for repo %s (path=%s, branch=%s): %s",
|
|
||||||
repo_id,
|
|
||||||
path,
|
|
||||||
branch,
|
|
||||||
str(e),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def get_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
branch: str,
|
|
||||||
path: str,
|
|
||||||
) -> FileContentResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
|
||||||
return FileContentResponse(
|
|
||||||
path=file_content.path,
|
|
||||||
branch=file_content.branch,
|
|
||||||
content=file_content.content,
|
|
||||||
size=file_content.size,
|
|
||||||
encoding=file_content.encoding,
|
|
||||||
language=file_content.language,
|
|
||||||
is_binary=file_content.is_binary,
|
|
||||||
last_commit=file_content.last_commit,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def update_file(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
data: FileUpdateRequest,
|
|
||||||
user: User,
|
|
||||||
) -> FileUpdateResponse:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
author_name = user.name or "Unknown"
|
|
||||||
author_email = user.email or "unknown@example.com"
|
|
||||||
try:
|
|
||||||
commit_hash = commit_file(
|
|
||||||
repo_path=repo.path,
|
|
||||||
branch=data.branch,
|
|
||||||
path=data.path,
|
|
||||||
content=data.content,
|
|
||||||
commit_message=data.commit_message,
|
|
||||||
author_name=author_name,
|
|
||||||
author_email=author_email,
|
|
||||||
)
|
|
||||||
return FileUpdateResponse(
|
|
||||||
commit_hash=commit_hash,
|
|
||||||
message=data.commit_message,
|
|
||||||
branch=data.branch,
|
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_branches_with_validation(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
) -> dict:
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
ensure_repo_on_disk(repo)
|
|
||||||
try:
|
|
||||||
branches, default_branch = list_branches(repo.path)
|
|
||||||
return {
|
|
||||||
"branches": [
|
|
||||||
{
|
|
||||||
"name": b.name,
|
|
||||||
"is_default": b.is_default,
|
|
||||||
"last_commit": b.last_commit,
|
|
||||||
}
|
|
||||||
for b in branches
|
|
||||||
],
|
|
||||||
"default_branch": default_branch,
|
|
||||||
}
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.error(
|
|
||||||
"Failed to list branches for repo %s: %s",
|
|
||||||
repo_id,
|
|
||||||
str(e),
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""Repository lifecycle and path helpers."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.config import Settings
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.user import User
|
|
||||||
from src.schemas.git_repository import GitRepositoryCreate
|
|
||||||
from src.utils.git_url_parser import parse_git_url
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str:
|
|
||||||
"""Generate the filesystem path for a repository."""
|
|
||||||
base = Settings().repo_base_path or "/data/repos"
|
|
||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_provider_clone_url(owner: str, repo: str) -> str:
|
|
||||||
"""Build the SSH clone URL for the fixed git provider."""
|
|
||||||
return f"git@git.commumedia.org:{owner}/{repo}.git"
|
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(remote_url: str) -> None:
|
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "ls-remote", remote_url],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="repository not found or inaccessible",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(remote_url: str, repo_path: str) -> None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "clone", remote_url, repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to clone repository: {result.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _init_working_repository(repo_path: str) -> None:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "init", "-b", "main", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
fallback = subprocess.run(
|
|
||||||
["git", "init", repo_path],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if fallback.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to initialize repository: {fallback.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
ref_result = subprocess.run(
|
|
||||||
["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if ref_result.returncode != 0:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"failed to set initial branch: {ref_result.stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_repo_and_validate(
|
|
||||||
session: AsyncSession,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> GitRepository:
|
|
||||||
"""Fetch a repository and validate ownership + disk presence."""
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
|
||||||
if repo is None or repo.project_id != project_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_repo_on_disk(repo: GitRepository) -> None:
|
|
||||||
"""Raise 404 if the repository is not present on disk."""
|
|
||||||
if not os.path.exists(repo.path):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_repository(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
data: GitRepositoryCreate,
|
|
||||||
user: User,
|
|
||||||
) -> GitRepository:
|
|
||||||
"""Create a new git repository (clone or init)."""
|
|
||||||
# Check for duplicate name
|
|
||||||
existing = await session.execute(
|
|
||||||
select(GitRepository).where(
|
|
||||||
GitRepository.project_id == project_id,
|
|
||||||
GitRepository.name == data.name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
|
||||||
|
|
||||||
# Validate and potentially correct the URL
|
|
||||||
remote_url = data.remote_url
|
|
||||||
if remote_url and not data.force_original_url:
|
|
||||||
parse_result = parse_git_url(remote_url)
|
|
||||||
if parse_result["needs_parsing"] and parse_result["base_url"]:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail={
|
|
||||||
"message": "The provided URL appears to be a browser URL, not a git clone URL",
|
|
||||||
"suggested_url": parse_result["base_url"],
|
|
||||||
"original_url": remote_url,
|
|
||||||
"error_code": "URL_NEEDS_PARSING",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if parse_result["base_url"]:
|
|
||||||
remote_url = parse_result["base_url"]
|
|
||||||
|
|
||||||
if remote_url:
|
|
||||||
_preflight_remote_repository(remote_url)
|
|
||||||
|
|
||||||
repo_path = _get_repo_path(user.id, project_id, data.name)
|
|
||||||
os.makedirs(os.path.dirname(repo_path), exist_ok=True)
|
|
||||||
|
|
||||||
if remote_url:
|
|
||||||
_clone_working_repository(remote_url, repo_path)
|
|
||||||
else:
|
|
||||||
_init_working_repository(repo_path)
|
|
||||||
|
|
||||||
repo = GitRepository(
|
|
||||||
name=data.name,
|
|
||||||
path=repo_path,
|
|
||||||
project_id=project_id,
|
|
||||||
owner_id=user.id,
|
|
||||||
is_mirror=False,
|
|
||||||
remote_url=remote_url,
|
|
||||||
)
|
|
||||||
session.add(repo)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(repo)
|
|
||||||
return repo
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_repository(
|
|
||||||
session: AsyncSession,
|
|
||||||
repo_id: uuid.UUID,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a repository from DB and disk."""
|
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
|
||||||
|
|
||||||
if os.path.exists(repo.path):
|
|
||||||
shutil.rmtree(repo.path)
|
|
||||||
|
|
||||||
await session.delete(repo)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_repositories(
|
|
||||||
session: AsyncSession,
|
|
||||||
project_id: uuid.UUID,
|
|
||||||
) -> list[GitRepository]:
|
|
||||||
"""List all repositories in a project."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all())
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
"""High-level tool instance lifecycle orchestration.
|
|
||||||
|
|
||||||
Coordinates Docker compose, container, tunnel, and config staging services
|
|
||||||
to create, start, stop, restart, and delete tool instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from src.models.config_folder import ConfigFolder
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
from src.models.git_repository import GitRepository
|
|
||||||
from src.models.project import Project
|
|
||||||
from src.models.tool_config import ToolConfig
|
|
||||||
from src.models.tool_instance import ToolInstance
|
|
||||||
from src.models.tool_type import ToolType
|
|
||||||
from src.models.user import User
|
|
||||||
from src.services.docker import compose as compose_svc
|
|
||||||
from src.services.docker import config_staging
|
|
||||||
from src.services.docker import container as container_svc
|
|
||||||
from src.services.docker import tunnel as tunnel_svc
|
|
||||||
from src.services.docker_build import build_image
|
|
||||||
from src.services.readiness_probe import execute_probe
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_new_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
project: Project,
|
|
||||||
repo: GitRepository,
|
|
||||||
tool_type: ToolType,
|
|
||||||
user: User,
|
|
||||||
display_name: str | None,
|
|
||||||
selected_profile: ConfigProfile | None,
|
|
||||||
) -> ToolInstance:
|
|
||||||
"""Create a new tool instance record and its compose file."""
|
|
||||||
instance_name = await compose_svc._generate_instance_name(
|
|
||||||
session, project.name, tool_type.name
|
|
||||||
)
|
|
||||||
instance_dir = compose_svc.ensure_instance_directory(instance_name)
|
|
||||||
tool_port = container_svc.find_free_port()
|
|
||||||
|
|
||||||
compose_path = await _build_or_render_compose(
|
|
||||||
tool_type, instance_name, instance_dir, repo, user, project.id, tool_port
|
|
||||||
)
|
|
||||||
|
|
||||||
instance = ToolInstance(
|
|
||||||
name=instance_name,
|
|
||||||
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
|
||||||
tool_type_id=tool_type.id,
|
|
||||||
repository_id=repo.id,
|
|
||||||
project_id=project.id,
|
|
||||||
owner_id=user.id,
|
|
||||||
status="pending",
|
|
||||||
compose_path=compose_path,
|
|
||||||
port=tool_port,
|
|
||||||
selected_profile_id=selected_profile.id if selected_profile else None,
|
|
||||||
)
|
|
||||||
session.add(instance)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(instance)
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
async def start_existing_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: ToolInstance,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
) -> dict:
|
|
||||||
"""Start an existing instance: stage configs, compose up, probe, tunnel."""
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
instance.status = "building"
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
|
||||||
session, user.id, instance.tool_type_id, project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
selected_profile = None
|
|
||||||
if instance.selected_profile_id:
|
|
||||||
selected_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
|
||||||
if selected_profile and selected_profile.user_id == user.id:
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
|
||||||
selected_profile,
|
|
||||||
instance_dir,
|
|
||||||
env_vars,
|
|
||||||
port_override,
|
|
||||||
start_command,
|
|
||||||
working_directory,
|
|
||||||
extra_volumes,
|
|
||||||
)
|
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
|
||||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
|
||||||
env_vars, config_files, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
|
||||||
compose_svc._modify_compose_file(
|
|
||||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
returncode, _stdout, stderr = compose_svc.execute_compose_command(
|
|
||||||
instance.compose_path, "up", env_file=env_file_path
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"failed to start instance: {stderr}",
|
|
||||||
)
|
|
||||||
|
|
||||||
container_id = container_svc.get_container_id(instance.name)
|
|
||||||
if container_id:
|
|
||||||
instance.container_id = container_id
|
|
||||||
container_name = container_svc.get_container_name(instance.name)
|
|
||||||
if container_name:
|
|
||||||
instance.container_name = container_name
|
|
||||||
container_svc.connect_container_to_network(container_name, "backend")
|
|
||||||
|
|
||||||
instance.status = "starting"
|
|
||||||
instance.last_started_at = datetime.now()
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
success, probe_logs = await _run_readiness_probe(instance, tool_type)
|
|
||||||
if not success:
|
|
||||||
instance.status = "failed"
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
await session.commit()
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"error": f"Readiness probe failed: {' '.join(probe_logs)}",
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.status = "running"
|
|
||||||
await session.commit()
|
|
||||||
await _start_tunnel_if_web(instance, tool_type)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
|
||||||
|
|
||||||
|
|
||||||
async def restart_existing_instance(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: ToolInstance,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
) -> dict:
|
|
||||||
"""Restart an instance: re-stage configs, compose restart, tunnel."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop old tunnel: %s", exc)
|
|
||||||
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
return {"status": instance.status}
|
|
||||||
|
|
||||||
env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs(
|
|
||||||
session, user.id, instance.tool_type_id, project_id
|
|
||||||
)
|
|
||||||
|
|
||||||
stored_profile = None
|
|
||||||
if instance.selected_profile_id:
|
|
||||||
stored_profile = await session.get(ConfigProfile, instance.selected_profile_id)
|
|
||||||
if stored_profile and stored_profile.user_id == user.id:
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile(
|
|
||||||
stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
env_file_path, extra_volumes = await _stage_configs_and_folders(
|
|
||||||
session, user.id, project_id, os.path.dirname(instance.compose_path),
|
|
||||||
env_vars, config_files, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
if port_override or start_command or working_directory or extra_volumes:
|
|
||||||
compose_svc._modify_compose_file(
|
|
||||||
instance.compose_path, port_override, start_command, working_directory, extra_volumes
|
|
||||||
)
|
|
||||||
|
|
||||||
returncode, _stdout, _stderr = compose_svc.execute_compose_command(
|
|
||||||
instance.compose_path, "restart", env_file=env_file_path
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
instance.status = "error"
|
|
||||||
await session.commit()
|
|
||||||
return {"status": instance.status}
|
|
||||||
|
|
||||||
instance.status = "running"
|
|
||||||
instance.last_started_at = datetime.now()
|
|
||||||
|
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
|
||||||
await _start_tunnel_if_web(instance, tool_type)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return {"status": instance.status, "url": instance.url}
|
|
||||||
|
|
||||||
|
|
||||||
async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
|
||||||
"""Stop an instance and its tunnel."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop tunnel: %s", exc)
|
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
|
||||||
compose_svc.execute_compose_command(instance.compose_path, "stop")
|
|
||||||
|
|
||||||
instance.status = "stopped"
|
|
||||||
instance.last_stopped_at = datetime.now()
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
instance.tunnel_id = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None:
|
|
||||||
"""Delete an instance, its containers, and its directory."""
|
|
||||||
if instance.tunnel_id:
|
|
||||||
try:
|
|
||||||
tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to stop tunnel: %s", exc)
|
|
||||||
|
|
||||||
if instance.compose_path and os.path.exists(instance.compose_path):
|
|
||||||
compose_svc.execute_compose_command(instance.compose_path, "down")
|
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
|
||||||
if os.path.exists(instance_dir):
|
|
||||||
shutil.rmtree(instance_dir)
|
|
||||||
|
|
||||||
await session.delete(instance)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _build_or_render_compose(
|
|
||||||
tool_type: ToolType,
|
|
||||||
instance_name: str,
|
|
||||||
instance_dir: str,
|
|
||||||
repo: GitRepository,
|
|
||||||
user: User,
|
|
||||||
project_id: Any,
|
|
||||||
tool_port: int,
|
|
||||||
) -> str:
|
|
||||||
"""Build Dockerfile or render compose template."""
|
|
||||||
if tool_type.definition_type == "dockerfile":
|
|
||||||
image_tag = f"headquarter/{instance_name}:latest"
|
|
||||||
if tool_type.dockerfile_template:
|
|
||||||
returncode, _stdout, stderr = build_image(
|
|
||||||
instance_dir=instance_dir,
|
|
||||||
dockerfile=tool_type.dockerfile_template,
|
|
||||||
tag=image_tag,
|
|
||||||
build_context=tool_type.build_context,
|
|
||||||
)
|
|
||||||
if returncode != 0:
|
|
||||||
logger.error("Build failed for %s: %s", instance_name, stderr)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to build Docker image: {stderr[:500]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
compose_content = (
|
|
||||||
f'version: "3.8"\nservices:\n app:\n'
|
|
||||||
f' image: {image_tag}\n'
|
|
||||||
f' container_name: {instance_name}\n'
|
|
||||||
f' ports:\n - "{tool_port}:{tool_type.default_port}"\n'
|
|
||||||
f' volumes:\n - {repo.path}:/workspace\n'
|
|
||||||
f' restart: unless-stopped\n'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
variables = {
|
|
||||||
"REPO_PATH": repo.path,
|
|
||||||
"INSTANCE_NAME": instance_name,
|
|
||||||
"INSTANCE_ID": instance_name,
|
|
||||||
"TOOL_NAME": instance_name,
|
|
||||||
"TOOL_PORT": tool_port,
|
|
||||||
"USER_ID": str(user.id),
|
|
||||||
"PROJECT_ID": str(project_id),
|
|
||||||
}
|
|
||||||
compose_content = compose_svc.render_compose_template(
|
|
||||||
tool_type.compose_template, variables
|
|
||||||
)
|
|
||||||
|
|
||||||
compose_svc.write_compose_file(instance_dir, compose_content)
|
|
||||||
return os.path.join(instance_dir, "docker-compose.yml")
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_tool_configs(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: Any,
|
|
||||||
tool_type_id: Any,
|
|
||||||
project_id: Any,
|
|
||||||
) -> tuple[dict, dict, Any, Any, Any, dict, list]:
|
|
||||||
"""Fetch tool configs and return parsed values."""
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
config_files: dict[str, str] = {}
|
|
||||||
port_override = None
|
|
||||||
start_command = None
|
|
||||||
working_directory = None
|
|
||||||
extra_env_vars: dict[str, str] = {}
|
|
||||||
extra_volumes: list[dict] = []
|
|
||||||
|
|
||||||
query = (
|
|
||||||
select(ToolConfig)
|
|
||||||
.where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id)
|
|
||||||
.where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None)))
|
|
||||||
)
|
|
||||||
configs = (await session.execute(query)).scalars().all()
|
|
||||||
|
|
||||||
for cfg in configs:
|
|
||||||
if cfg.config_type == "env":
|
|
||||||
env_vars[cfg.key] = cfg.value
|
|
||||||
elif cfg.config_type == "file" and cfg.file_path:
|
|
||||||
config_files[cfg.file_path] = cfg.value
|
|
||||||
if cfg.port_override:
|
|
||||||
port_override = cfg.port_override
|
|
||||||
if cfg.start_command:
|
|
||||||
start_command = cfg.start_command
|
|
||||||
if cfg.working_directory:
|
|
||||||
working_directory = cfg.working_directory
|
|
||||||
if cfg.environment_variables:
|
|
||||||
extra_env_vars.update(cfg.environment_variables)
|
|
||||||
if cfg.volumes:
|
|
||||||
extra_volumes.extend(cfg.volumes)
|
|
||||||
|
|
||||||
env_vars.update(extra_env_vars)
|
|
||||||
return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
async def _stage_configs_and_folders(
|
|
||||||
session: AsyncSession,
|
|
||||||
user_id: Any,
|
|
||||||
project_id: Any,
|
|
||||||
instance_dir: str,
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
config_files: dict[str, str],
|
|
||||||
extra_volumes: list[dict],
|
|
||||||
) -> tuple[str | None, list[dict]]:
|
|
||||||
"""Write env/config files and config folders."""
|
|
||||||
env_file_path: str | None = None
|
|
||||||
if env_vars:
|
|
||||||
env_file_path = compose_svc.write_env_file(instance_dir, env_vars)
|
|
||||||
if config_files:
|
|
||||||
config_staging.write_config_files(instance_dir, config_files)
|
|
||||||
|
|
||||||
folder_query = select(ConfigFolder).where(
|
|
||||||
ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True)
|
|
||||||
)
|
|
||||||
folders = (await session.execute(folder_query)).scalars().all()
|
|
||||||
if folders:
|
|
||||||
folder_volumes = config_staging.write_config_folder_files(
|
|
||||||
instance_dir, folders, str(project_id)
|
|
||||||
)
|
|
||||||
extra_volumes.extend(folder_volumes)
|
|
||||||
|
|
||||||
return env_file_path, extra_volumes
|
|
||||||
|
|
||||||
|
|
||||||
async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None:
|
|
||||||
"""Create Cloudflare tunnel for web-enabled tools."""
|
|
||||||
if "web" not in tool_type.interfaces or not tool_type.default_port:
|
|
||||||
instance.url = None
|
|
||||||
instance.public_url = None
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
tunnel_info = tunnel_svc.start_cloudflared_tunnel(
|
|
||||||
container_name=instance.container_name or instance.name,
|
|
||||||
port=tool_type.default_port,
|
|
||||||
)
|
|
||||||
instance.tunnel_id = tunnel_info["pid"]
|
|
||||||
instance.public_url = tunnel_info["url"]
|
|
||||||
instance.url = tunnel_info["url"]
|
|
||||||
logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc)
|
|
||||||
instance.status = "error"
|
|
||||||
instance.url = None
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_readiness_probe(
|
|
||||||
instance: ToolInstance, tool_type: ToolType
|
|
||||||
) -> tuple[bool, list[str]]:
|
|
||||||
"""Run readiness probe if configured."""
|
|
||||||
if not tool_type.readiness_probe or not instance.container_id:
|
|
||||||
return True, []
|
|
||||||
|
|
||||||
probe = tool_type.readiness_probe
|
|
||||||
command = probe.get("command", "")
|
|
||||||
if not command:
|
|
||||||
return True, []
|
|
||||||
|
|
||||||
return await execute_probe(
|
|
||||||
container_id=instance.container_id,
|
|
||||||
command=command,
|
|
||||||
timeout=probe.get("timeout", 30),
|
|
||||||
interval=probe.get("interval", 2),
|
|
||||||
)
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
"""Profile resolver service for recursive ordered include resolution.
|
|
||||||
|
|
||||||
Provides deterministic merge rules, save-independent cycle protection,
|
|
||||||
and resolved output structures for env vars, runtime hints, mounts,
|
|
||||||
file trees, and override metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
from src.models.config_include import ConfigInclude
|
|
||||||
from src.models.config_mount import ConfigMount
|
|
||||||
from src.models.config_profile import ConfigProfile
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedMount:
|
|
||||||
"""A resolved mount with merged file tree and final mode."""
|
|
||||||
|
|
||||||
target_path: str
|
|
||||||
mode: str # "ro" or "rw"
|
|
||||||
files: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Relative file paths to UTF-8 text content."""
|
|
||||||
overridden_files: dict[str, list[str]] = field(default_factory=dict)
|
|
||||||
"""Map of relative file path to list of profile names that contributed
|
|
||||||
(latest is the winner)."""
|
|
||||||
mode_overridden_by: str | None = None
|
|
||||||
"""Name of the profile that set the final mode, if different from first."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedRuntimeHints:
|
|
||||||
"""Resolved runtime hints from profile layers."""
|
|
||||||
|
|
||||||
start_command: str | None = None
|
|
||||||
working_directory: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
overridden_hints: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Map of hint key to profile name that provided the winning value."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResolvedProfileOutput:
|
|
||||||
"""Complete resolved output for a config profile."""
|
|
||||||
|
|
||||||
profile_id: uuid.UUID
|
|
||||||
profile_name: str
|
|
||||||
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
||||||
"""Final merged env vars (later layers win)."""
|
|
||||||
env_var_sources: dict[str, list[str]] = field(default_factory=dict)
|
|
||||||
"""Map of env var key to ordered list of contributing profile names
|
|
||||||
(latest is the winner)."""
|
|
||||||
runtime_hints: ResolvedRuntimeHints = field(
|
|
||||||
default_factory=lambda: ResolvedRuntimeHints()
|
|
||||||
)
|
|
||||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
|
||||||
"""Map of target_path to ResolvedMount."""
|
|
||||||
resolution_order: list[str] = field(default_factory=list)
|
|
||||||
"""Ordered list of profile names as they were resolved."""
|
|
||||||
cycle_detected: bool = False
|
|
||||||
cycle_path: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileResolutionError(Exception):
|
|
||||||
"""Raised when profile resolution fails."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileCycleError(ProfileResolutionError):
|
|
||||||
"""Raised when a cycle is detected during profile resolution."""
|
|
||||||
|
|
||||||
def __init__(self, cycle_path: list[str]) -> None:
|
|
||||||
self.cycle_path = cycle_path
|
|
||||||
path_str = " -> ".join(cycle_path)
|
|
||||||
super().__init__(f"Profile include cycle detected: {path_str}")
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_env_vars(
|
|
||||||
current: dict[str, str],
|
|
||||||
sources: dict[str, list[str]],
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's env vars into the current dict, tracking sources."""
|
|
||||||
if not profile.environment_variables:
|
|
||||||
return
|
|
||||||
for key, value in profile.environment_variables.items():
|
|
||||||
current[key] = value
|
|
||||||
if key not in sources:
|
|
||||||
sources[key] = []
|
|
||||||
sources[key].append(profile.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_runtime_hints(
|
|
||||||
hints: ResolvedRuntimeHints,
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's runtime hints, tracking overrides."""
|
|
||||||
if profile.start_command is not None:
|
|
||||||
hints.start_command = profile.start_command
|
|
||||||
hints.overridden_hints["start_command"] = profile.name
|
|
||||||
if profile.working_directory is not None:
|
|
||||||
hints.working_directory = profile.working_directory
|
|
||||||
hints.overridden_hints["working_directory"] = profile.name
|
|
||||||
if profile.port is not None:
|
|
||||||
hints.port = profile.port
|
|
||||||
hints.overridden_hints["port"] = profile.name
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_mounts(
|
|
||||||
mounts: dict[str, ResolvedMount],
|
|
||||||
profile_mounts: list[ConfigMount],
|
|
||||||
profile: ConfigProfile,
|
|
||||||
) -> None:
|
|
||||||
"""Merge a profile's mounts into the current mounts dict."""
|
|
||||||
for mount in profile_mounts:
|
|
||||||
target = mount.target_path
|
|
||||||
if target not in mounts:
|
|
||||||
mounts[target] = ResolvedMount(
|
|
||||||
target_path=target,
|
|
||||||
mode=mount.mode,
|
|
||||||
files={},
|
|
||||||
overridden_files={},
|
|
||||||
)
|
|
||||||
resolved = mounts[target]
|
|
||||||
|
|
||||||
# Mode override: later wins
|
|
||||||
if resolved.mode != mount.mode:
|
|
||||||
resolved.mode = mount.mode
|
|
||||||
resolved.mode_overridden_by = profile.name
|
|
||||||
|
|
||||||
# File tree merge: later wins for same relative path
|
|
||||||
if mount.files:
|
|
||||||
for rel_path, content in mount.files.items():
|
|
||||||
if rel_path not in resolved.files:
|
|
||||||
resolved.overridden_files[rel_path] = []
|
|
||||||
else:
|
|
||||||
if rel_path not in resolved.overridden_files:
|
|
||||||
resolved.overridden_files[rel_path] = []
|
|
||||||
resolved.overridden_files[rel_path].append(profile.name)
|
|
||||||
resolved.files[rel_path] = content
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_profile_recursive(
|
|
||||||
profile: ConfigProfile,
|
|
||||||
visited: set[uuid.UUID],
|
|
||||||
path: list[str],
|
|
||||||
resolution_order: list[str],
|
|
||||||
env_vars: dict[str, str],
|
|
||||||
env_var_sources: dict[str, list[str]],
|
|
||||||
runtime_hints: ResolvedRuntimeHints,
|
|
||||||
mounts: dict[str, ResolvedMount],
|
|
||||||
) -> None:
|
|
||||||
"""Recursively resolve a profile and its includes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: The profile to resolve
|
|
||||||
visited: Set of already-resolved profile IDs to avoid duplicates
|
|
||||||
path: Current recursion path for cycle detection
|
|
||||||
resolution_order: Ordered list of profile names being resolved
|
|
||||||
env_vars: Accumulated environment variables
|
|
||||||
env_var_sources: Tracking of which profiles contributed each env var
|
|
||||||
runtime_hints: Accumulated runtime hints
|
|
||||||
mounts: Accumulated mounts
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProfileCycleError: If a cycle is detected
|
|
||||||
"""
|
|
||||||
if profile.name in path:
|
|
||||||
# Cycle detected
|
|
||||||
cycle_start = path.index(profile.name)
|
|
||||||
cycle_path = path[cycle_start:] + [profile.name]
|
|
||||||
raise ProfileCycleError(cycle_path)
|
|
||||||
|
|
||||||
if profile.id in visited:
|
|
||||||
# Already resolved in another branch (diamond graph)
|
|
||||||
return
|
|
||||||
|
|
||||||
visited.add(profile.id)
|
|
||||||
path.append(profile.name)
|
|
||||||
resolution_order.append(profile.name)
|
|
||||||
|
|
||||||
# Resolve includes first (in order)
|
|
||||||
includes: list[ConfigInclude] = list(profile.includes)
|
|
||||||
includes.sort(key=lambda inc: inc.order_index)
|
|
||||||
for include in includes:
|
|
||||||
included_profile = include.included_profile
|
|
||||||
if included_profile is not None:
|
|
||||||
_resolve_profile_recursive(
|
|
||||||
included_profile,
|
|
||||||
visited,
|
|
||||||
path,
|
|
||||||
resolution_order,
|
|
||||||
env_vars,
|
|
||||||
env_var_sources,
|
|
||||||
runtime_hints,
|
|
||||||
mounts,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply this profile's values (later layers win)
|
|
||||||
_merge_env_vars(env_vars, env_var_sources, profile)
|
|
||||||
_merge_runtime_hints(runtime_hints, profile)
|
|
||||||
_merge_mounts(mounts, list(profile.mounts), profile)
|
|
||||||
|
|
||||||
path.pop()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput:
|
|
||||||
"""Resolve a config profile with all its includes.
|
|
||||||
|
|
||||||
Processes included profiles in configured order, then applies the
|
|
||||||
selected profile itself. Later layers override earlier layers.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile: The root profile to resolve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ResolvedProfileOutput with merged env vars, runtime hints, mounts,
|
|
||||||
and override metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ProfileCycleError: If a cycle is detected in the include graph
|
|
||||||
"""
|
|
||||||
env_vars: dict[str, str] = {}
|
|
||||||
env_var_sources: dict[str, list[str]] = {}
|
|
||||||
runtime_hints = ResolvedRuntimeHints()
|
|
||||||
mounts: dict[str, ResolvedMount] = {}
|
|
||||||
resolution_order: list[str] = []
|
|
||||||
|
|
||||||
_resolve_profile_recursive(
|
|
||||||
profile,
|
|
||||||
set(),
|
|
||||||
[],
|
|
||||||
resolution_order,
|
|
||||||
env_vars,
|
|
||||||
env_var_sources,
|
|
||||||
runtime_hints,
|
|
||||||
mounts,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ResolvedProfileOutput(
|
|
||||||
profile_id=profile.id,
|
|
||||||
profile_name=profile.name,
|
|
||||||
environment_variables=env_vars,
|
|
||||||
env_var_sources=env_var_sources,
|
|
||||||
runtime_hints=runtime_hints,
|
|
||||||
mounts=mounts,
|
|
||||||
resolution_order=resolution_order,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet() -> Fernet:
|
||||||
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
key_bytes = hashlib.sha256(settings.session_secret.encode()).digest()
|
||||||
|
key = base64.urlsafe_b64encode(key_bytes)
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
||||||
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the .ssh directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Decrypt private key
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
|
# Write private key with restricted permissions
|
||||||
|
private_key_path = ssh_dir / "id_ed25519"
|
||||||
|
private_key_path.write_text(private_key)
|
||||||
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
|
# Write public key
|
||||||
|
public_key_path = ssh_dir / "id_ed25519.pub"
|
||||||
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
|
# Write SSH config
|
||||||
|
config_path = ssh_dir / "config"
|
||||||
|
config_content = """Host *
|
||||||
|
StrictHostKeyChecking no
|
||||||
|
UserKnownHostsFile /dev/null
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
"""
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
|
"""Remove temporary SSH key files from instance directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_dir: Path to instance directory
|
||||||
|
"""
|
||||||
|
ssh_dir = Path(instance_dir) / ".ssh"
|
||||||
|
if ssh_dir.exists():
|
||||||
|
for file_path in ssh_dir.iterdir():
|
||||||
|
file_path.unlink()
|
||||||
|
ssh_dir.rmdir()
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
"""Terminal session manager for WebSocket connections."""
|
"""Terminal session manager for WebSocket connections."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Coroutine
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
@@ -15,179 +10,152 @@ from src.services.terminal_session import TerminalSession
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay
|
|
||||||
_READ_POLL_TIMEOUT_S = 0.005
|
|
||||||
_READ_POLL_SLEEP_S = 0.001
|
|
||||||
_HEARTBEAT_INTERVAL_S = 15.0
|
|
||||||
_IDLE_TIMEOUT_S = 60.0
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalManager:
|
class TerminalManager:
|
||||||
"""Manages active terminal sessions."""
|
"""Manages active terminal sessions with persistence support."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialise the terminal manager."""
|
# Track sessions by instance_id for persistence
|
||||||
self._sessions: dict[str, TerminalSession] = {}
|
self._sessions: dict[str, TerminalSession] = {}
|
||||||
self._last_client_message: dict[str, float] = {}
|
self._idle_check_task: asyncio.Task | None = None
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._start_idle_check()
|
||||||
|
|
||||||
async def create_session(
|
def _start_idle_check(self) -> None:
|
||||||
|
"""Start the idle timeout background task."""
|
||||||
|
if self._idle_check_task is not None and not self._idle_check_task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._idle_check_task = loop.create_task(self._idle_check_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop running yet, will be started lazily
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _idle_check_loop(self) -> None:
|
||||||
|
"""Periodically check for idle sessions and clean them up."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60) # Check every minute
|
||||||
|
await self._cleanup_idle_sessions()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in idle check loop: %s", exc)
|
||||||
|
|
||||||
|
async def _cleanup_idle_sessions(self) -> None:
|
||||||
|
"""Clean up sessions that have been idle for too long."""
|
||||||
|
idle_sessions = []
|
||||||
|
for instance_id, session in list(self._sessions.items()):
|
||||||
|
if session.is_idle():
|
||||||
|
idle_sessions.append(instance_id)
|
||||||
|
|
||||||
|
for instance_id in idle_sessions:
|
||||||
|
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
|
||||||
|
session = self._sessions.pop(instance_id, None)
|
||||||
|
if session:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
async def get_or_create_session(
|
||||||
self,
|
self,
|
||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
container_id: str,
|
container_id: str,
|
||||||
websocket: WebSocket,
|
startup_command: str | None = None,
|
||||||
) -> TerminalSession:
|
) -> TerminalSession:
|
||||||
"""Create a new terminal session."""
|
"""Get existing session or create a new one."""
|
||||||
session_id = str(uuid.uuid4())
|
# Ensure idle check is running (lazy start)
|
||||||
session = TerminalSession(session_id, instance_id, container_id)
|
self._start_idle_check()
|
||||||
await session.start()
|
|
||||||
self._sessions[session_id] = session
|
|
||||||
self._last_client_message[session_id] = time.monotonic()
|
|
||||||
|
|
||||||
# Start background tasks for I/O streaming
|
instance_id_str = str(instance_id)
|
||||||
self._start_task(self._read_loop(session, websocket))
|
|
||||||
self._start_task(self._write_loop(session, websocket))
|
# Check for existing session
|
||||||
self._start_task(self._heartbeat_loop(session, websocket))
|
if instance_id_str in self._sessions:
|
||||||
|
session = self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Check if session is still alive
|
||||||
|
if session.is_alive():
|
||||||
|
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
|
||||||
|
return session
|
||||||
|
else:
|
||||||
|
# Session died, clean it up
|
||||||
|
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
|
||||||
|
await session.close()
|
||||||
|
del self._sessions[instance_id_str]
|
||||||
|
|
||||||
|
# Create new session
|
||||||
|
logger.info("Creating new terminal session for instance %s", instance_id)
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
|
await session.start(startup_command=startup_command)
|
||||||
|
self._sessions[instance_id_str] = session
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def _start_task(self, coro: Coroutine[Any, Any, None]) -> None:
|
async def attach_websocket(
|
||||||
"""Start a background task and store a reference to prevent GC."""
|
|
||||||
task = asyncio.create_task(coro)
|
|
||||||
self._background_tasks.add(task)
|
|
||||||
task.add_done_callback(self._background_tasks.discard)
|
|
||||||
|
|
||||||
async def _read_loop(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
session: TerminalSession,
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Read output from the container and send to WebSocket with batching."""
|
"""Attach a WebSocket to an existing session."""
|
||||||
try:
|
# Handle concurrent connections - close existing ones
|
||||||
buffer = bytearray()
|
if session.has_websockets():
|
||||||
last_flush = time.monotonic()
|
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
|
||||||
|
for ws in list(session._websockets):
|
||||||
|
try:
|
||||||
|
await ws.close(code=4000, reason="New connection established")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
session._websockets.clear()
|
||||||
|
|
||||||
while session.is_alive() and not session.closed:
|
# Attach new WebSocket
|
||||||
data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S)
|
session.attach_websocket(websocket)
|
||||||
if data:
|
|
||||||
buffer.extend(data)
|
|
||||||
|
|
||||||
now = time.monotonic()
|
# Replay buffer
|
||||||
flush_due = buffer and (
|
buffer = session.get_buffer()
|
||||||
now - last_flush >= _READ_BATCH_INTERVAL_S or not data
|
if buffer:
|
||||||
)
|
try:
|
||||||
|
await websocket.send_bytes(buffer)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if flush_due:
|
async def detach_websocket(
|
||||||
await websocket.send_bytes(bytes(buffer))
|
|
||||||
buffer.clear()
|
|
||||||
last_flush = now
|
|
||||||
elif not data:
|
|
||||||
await asyncio.sleep(_READ_POLL_SLEEP_S)
|
|
||||||
|
|
||||||
# Flush any remaining data
|
|
||||||
if buffer:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await websocket.send_bytes(bytes(buffer))
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Read loop error for session %s", session.session_id)
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _write_loop(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
session: TerminalSession,
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Detach a WebSocket from a session."""
|
||||||
try:
|
session.detach_websocket(websocket)
|
||||||
while session.is_alive() and not session.closed:
|
|
||||||
message = await websocket.receive()
|
|
||||||
self._last_client_message[session.session_id] = time.monotonic()
|
|
||||||
|
|
||||||
if message["type"] == "websocket.receive":
|
async def reset_session(
|
||||||
if "bytes" in message:
|
|
||||||
await session.write_input(message["bytes"])
|
|
||||||
elif "text" in message:
|
|
||||||
text = message["text"]
|
|
||||||
if text.startswith("{"):
|
|
||||||
try:
|
|
||||||
ctrl = json.loads(text)
|
|
||||||
await self._handle_control_message(
|
|
||||||
session,
|
|
||||||
websocket,
|
|
||||||
ctrl,
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.debug("Invalid JSON control message: %s", text)
|
|
||||||
else:
|
|
||||||
await session.write_input(text.encode("utf-8"))
|
|
||||||
elif message["type"] == "websocket.disconnect":
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Write loop error for session %s", session.session_id)
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _handle_control_message(
|
|
||||||
self,
|
self,
|
||||||
session: TerminalSession,
|
instance_id: uuid.UUID,
|
||||||
websocket: WebSocket,
|
container_id: str,
|
||||||
ctrl: dict[str, Any],
|
startup_command: str | None = None,
|
||||||
) -> None:
|
) -> TerminalSession:
|
||||||
"""Handle a JSON control message from the client."""
|
"""Reset a session by killing it and creating a new one."""
|
||||||
msg_type = ctrl.get("type")
|
instance_id_str = str(instance_id)
|
||||||
if msg_type == "resize":
|
|
||||||
await session.resize(
|
|
||||||
ctrl.get("cols", 80),
|
|
||||||
ctrl.get("rows", 24),
|
|
||||||
)
|
|
||||||
elif msg_type == "ping":
|
|
||||||
await websocket.send_json(
|
|
||||||
{"type": "pong", "id": ctrl.get("id")},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _heartbeat_loop(
|
# Close existing session if any
|
||||||
self,
|
if instance_id_str in self._sessions:
|
||||||
session: TerminalSession,
|
logger.debug("Resetting terminal session for instance %s", instance_id)
|
||||||
websocket: WebSocket,
|
old_session = self._sessions.pop(instance_id_str)
|
||||||
) -> None:
|
await old_session.close()
|
||||||
"""Monitor client activity and close idle connections."""
|
|
||||||
try:
|
|
||||||
while session.is_alive() and not session.closed:
|
|
||||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
|
||||||
last_msg = self._last_client_message.get(session.session_id, 0)
|
|
||||||
if time.monotonic() - last_msg > _IDLE_TIMEOUT_S:
|
|
||||||
# Client has been silent for 60s — close connection
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await websocket.close(
|
|
||||||
code=1000,
|
|
||||||
reason="Idle timeout",
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Heartbeat loop error for session %s",
|
|
||||||
session.session_id,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await self._cleanup_session(session)
|
|
||||||
|
|
||||||
async def _cleanup_session(self, session: TerminalSession) -> None:
|
# Create new session
|
||||||
"""Clean up a session."""
|
session_id = str(uuid.uuid4())
|
||||||
if session.session_id in self._sessions:
|
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
|
||||||
del self._sessions[session.session_id]
|
await session.start(startup_command=startup_command)
|
||||||
self._last_client_message.pop(session.session_id, None)
|
self._sessions[instance_id_str] = session
|
||||||
await session.close()
|
|
||||||
|
return session
|
||||||
|
|
||||||
async def close_all(self) -> None:
|
async def close_all(self) -> None:
|
||||||
"""Close all active sessions."""
|
"""Close all active sessions."""
|
||||||
sessions = list(self._sessions.values())
|
sessions = list(self._sessions.values())
|
||||||
self._sessions.clear()
|
self._sessions.clear()
|
||||||
self._last_client_message.clear()
|
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
if self._idle_check_task and not self._idle_check_task.done():
|
||||||
|
self._idle_check_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
# Global terminal manager instance
|
# Global terminal manager instance
|
||||||
terminal_manager = TerminalManager()
|
terminal_manager = TerminalManager()
|
||||||
|
|||||||
@@ -1,132 +1,191 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""Terminal session management for tool instances."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
|
||||||
import fcntl
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import select
|
import select
|
||||||
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import termios
|
import fcntl
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session connected to a docker container."""
|
"""Manages a single terminal session connected to a docker container.
|
||||||
|
|
||||||
def __init__(
|
Supports persistent sessions that survive WebSocket disconnections.
|
||||||
self,
|
Multiple WebSocket connections can attach/detach from the same session.
|
||||||
session_id: str,
|
"""
|
||||||
instance_id: uuid.UUID,
|
|
||||||
container_id: str,
|
# Circular buffer size (10KB)
|
||||||
) -> None:
|
BUFFER_SIZE = 10 * 1024
|
||||||
"""Initialize a terminal session."""
|
|
||||||
|
# Idle timeout in seconds (30 minutes)
|
||||||
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.instance_id = instance_id
|
self.instance_id = instance_id
|
||||||
self.container_id = container_id
|
self.container_id = container_id
|
||||||
|
self.startup_command = startup_command
|
||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
self._slave_fd: int | None = None
|
self._slave_fd: int | None = None
|
||||||
self._echo_enabled = True
|
|
||||||
self._exit_reason: str | None = None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
# Circular buffer for output replay
|
||||||
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||||
|
self._buffer_size = 0
|
||||||
|
|
||||||
|
# WebSocket connections
|
||||||
|
self._websockets: set[Any] = set()
|
||||||
|
|
||||||
|
# Activity tracking
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
# Terminal size
|
||||||
|
self._cols = 80
|
||||||
|
self._rows = 24
|
||||||
|
|
||||||
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, self._slave_fd = pty.openpty()
|
||||||
self._set_terminal_size(80, 24)
|
|
||||||
|
|
||||||
|
# Set the terminal size initially
|
||||||
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
|
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
|
||||||
|
|
||||||
|
# Build the shell command
|
||||||
|
if startup_command:
|
||||||
|
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
||||||
|
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
|
||||||
|
else:
|
||||||
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
|
# Using -it because the slave fd IS a TTY
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"exec",
|
"exec",
|
||||||
"-it",
|
"-it",
|
||||||
"-e",
|
"-e",
|
||||||
"TERM=xterm-256color",
|
"TERM=xterm",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"bash",
|
"bash",
|
||||||
"-il",
|
"-c",
|
||||||
|
shell_cmd,
|
||||||
stdin=self._slave_fd,
|
stdin=self._slave_fd,
|
||||||
stdout=self._slave_fd,
|
stdout=self._slave_fd,
|
||||||
stderr=self._slave_fd,
|
stderr=self._slave_fd,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Close slave fd in parent process
|
||||||
os.close(self._slave_fd)
|
os.close(self._slave_fd)
|
||||||
self._slave_fd = None
|
self._slave_fd = None
|
||||||
self._echo_enabled = self._detect_echo_state()
|
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
"""Set the terminal size using TIOCSWINSZ."""
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
if self._master_fd is None:
|
if self._master_fd is None:
|
||||||
|
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||||
return
|
return
|
||||||
tiocswinsz = 0x5414
|
# TIOCSWINSZ = 0x5414 on Linux
|
||||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
TIOCSWINSZ = 0x5414
|
||||||
with contextlib.suppress(OSError):
|
size = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
fcntl.ioctl(self._master_fd, tiocswinsz, size)
|
|
||||||
|
|
||||||
def _detect_echo_state(self) -> bool:
|
|
||||||
"""Detect whether the PTY has echo enabled via termios."""
|
|
||||||
if self._master_fd is None:
|
|
||||||
return True
|
|
||||||
try:
|
try:
|
||||||
attrs = termios.tcgetattr(self._master_fd)
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
return bool(attrs[3] & termios.ECHO)
|
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
||||||
except OSError:
|
except (OSError, IOError) as e:
|
||||||
return True
|
logger.error(f"Failed to resize PTY: {e}")
|
||||||
|
|
||||||
async def check_echo_state(self) -> bool | None:
|
async def read_output(self) -> bytes:
|
||||||
"""Check if echo state changed. Returns new state if changed, None otherwise."""
|
"""Read output from the PTY master and store in buffer."""
|
||||||
current = self._detect_echo_state()
|
|
||||||
if current != self._echo_enabled:
|
|
||||||
self._echo_enabled = current
|
|
||||||
return current
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def echo_enabled(self) -> bool:
|
|
||||||
"""Return whether the PTY currently has echo enabled."""
|
|
||||||
return self._echo_enabled
|
|
||||||
|
|
||||||
@property
|
|
||||||
def closed(self) -> bool:
|
|
||||||
"""Return whether the session has been closed."""
|
|
||||||
return self._closed
|
|
||||||
|
|
||||||
async def read_output(self, select_timeout: float = 0.1) -> bytes:
|
|
||||||
"""Read output from the PTY master."""
|
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return b""
|
return b""
|
||||||
try:
|
try:
|
||||||
readable, _, _ = select.select(
|
# Use select to check if data is available
|
||||||
[self._master_fd],
|
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
||||||
[],
|
|
||||||
[],
|
|
||||||
select_timeout,
|
|
||||||
)
|
|
||||||
if readable:
|
if readable:
|
||||||
return os.read(self._master_fd, 8192)
|
data = os.read(self._master_fd, 4096)
|
||||||
|
if data:
|
||||||
|
self._add_to_buffer(data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
return data
|
||||||
return b""
|
return b""
|
||||||
except (OSError, ValueError):
|
except (OSError, IOError, ValueError):
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
def _add_to_buffer(self, data: bytes) -> None:
|
||||||
|
"""Add data to circular buffer, maintaining size limit."""
|
||||||
|
self._output_buffer.append(data)
|
||||||
|
self._buffer_size += len(data)
|
||||||
|
|
||||||
|
# Trim if exceeds max size
|
||||||
|
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||||
|
removed = self._output_buffer.popleft()
|
||||||
|
self._buffer_size -= len(removed)
|
||||||
|
|
||||||
|
def get_buffer(self) -> bytes:
|
||||||
|
"""Get buffered output for replay."""
|
||||||
|
return b"".join(self._output_buffer)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write input to the PTY master."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
return
|
return
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.write(self._master_fd, data)
|
os.write(self._master_fd, data)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
except (OSError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
logger.warning("Cannot resize: session is closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only resize if dimensions actually changed
|
||||||
|
if cols == self._cols and rows == self._rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._cols = cols
|
||||||
|
self._rows = rows
|
||||||
|
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
def get_exit_reason(self) -> str | None:
|
# Docker exec -it creates its own PTY inside the container,
|
||||||
"""Return the reason the session ended, if known."""
|
# so host PTY resize doesn't propagate to the container shell.
|
||||||
return self._exit_reason
|
# Send SIGWINCH to the docker exec process on the host.
|
||||||
|
# Docker exec forwards signals to the container process, which should
|
||||||
|
# cause the container's shell to re-read its terminal size.
|
||||||
|
if self.process and self.process.pid:
|
||||||
|
try:
|
||||||
|
os.kill(self.process.pid, signal.SIGWINCH)
|
||||||
|
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
|
||||||
|
except ProcessLookupError:
|
||||||
|
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send SIGWINCH: {e}")
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
"""Reset the session by killing the process and clearing state."""
|
||||||
|
await self.close()
|
||||||
|
self._closed = False
|
||||||
|
self._output_buffer.clear()
|
||||||
|
self._buffer_size = 0
|
||||||
|
self._websockets.clear()
|
||||||
|
self.process = None
|
||||||
|
self._master_fd = None
|
||||||
|
self._slave_fd = None
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the session and cleanup."""
|
"""Close the session and cleanup."""
|
||||||
@@ -134,25 +193,18 @@ class TerminalSession:
|
|||||||
return
|
return
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
# Determine exit reason
|
|
||||||
if self.process is not None and self.process.returncode is not None:
|
|
||||||
if self.process.returncode == 0:
|
|
||||||
self._exit_reason = "process_exit"
|
|
||||||
else:
|
|
||||||
self._exit_reason = "process_exit"
|
|
||||||
else:
|
|
||||||
self._exit_reason = "timeout"
|
|
||||||
|
|
||||||
if self._master_fd is not None:
|
if self._master_fd is not None:
|
||||||
with contextlib.suppress(OSError):
|
try:
|
||||||
os.close(self._master_fd)
|
os.close(self._master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
self._master_fd = None
|
self._master_fd = None
|
||||||
|
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
try:
|
try:
|
||||||
self.process.kill()
|
self.process.kill()
|
||||||
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
||||||
except (TimeoutError, ProcessLookupError):
|
except (asyncio.TimeoutError, ProcessLookupError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def is_alive(self) -> bool:
|
def is_alive(self) -> bool:
|
||||||
@@ -160,3 +212,35 @@ class TerminalSession:
|
|||||||
if self.process is None:
|
if self.process is None:
|
||||||
return False
|
return False
|
||||||
return self.process.returncode is None
|
return self.process.returncode is None
|
||||||
|
|
||||||
|
def is_idle(self) -> bool:
|
||||||
|
"""Check if the session has been idle for too long."""
|
||||||
|
if self._websockets:
|
||||||
|
return False
|
||||||
|
return time.time() - self.last_activity > self.IDLE_TIMEOUT
|
||||||
|
|
||||||
|
def attach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Attach a WebSocket to this session."""
|
||||||
|
self._websockets.add(websocket)
|
||||||
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def detach_websocket(self, websocket: Any) -> None:
|
||||||
|
"""Detach a WebSocket from this session."""
|
||||||
|
self._websockets.discard(websocket)
|
||||||
|
|
||||||
|
def has_websockets(self) -> bool:
|
||||||
|
"""Check if any WebSockets are attached."""
|
||||||
|
return len(self._websockets) > 0
|
||||||
|
|
||||||
|
async def send_to_all(self, data: bytes) -> None:
|
||||||
|
"""Send data to all attached WebSockets."""
|
||||||
|
dead_sockets = set()
|
||||||
|
for ws in self._websockets:
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(data)
|
||||||
|
except Exception:
|
||||||
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
|
# Clean up dead sockets
|
||||||
|
for ws in dead_sockets:
|
||||||
|
self._websockets.discard(ws)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def _run_git_command(repo_path: str, *args: str) -> str:
|
def _run_git_command(repo_path: str, *args: str) -> str:
|
||||||
@@ -124,7 +123,15 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None:
|
|||||||
try:
|
try:
|
||||||
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
_run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
_run_git_command(repo_path, "checkout", "--orphan", name)
|
# No commits yet - empty repository
|
||||||
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", "--orphan", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
return
|
return
|
||||||
|
|
||||||
_run_git_command(repo_path, "branch", name, base_branch)
|
_run_git_command(repo_path, "branch", name, base_branch)
|
||||||
@@ -155,7 +162,14 @@ def checkout_branch(repo_path: str, name: str) -> None:
|
|||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If checkout fails
|
RuntimeError: If checkout fails
|
||||||
"""
|
"""
|
||||||
_run_git_command(repo_path, "checkout", name)
|
try:
|
||||||
|
_run_git_command(repo_path, "checkout", name)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "work tree" in str(e).lower():
|
||||||
|
# Bare repository - use symbolic-ref instead
|
||||||
|
_run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}")
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def commit_changes(
|
def commit_changes(
|
||||||
@@ -290,6 +304,10 @@ def get_current_branch(repo_path: str) -> str:
|
|||||||
Current branch name
|
Current branch name
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
||||||
|
if branch != "HEAD":
|
||||||
|
return branch
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
pass
|
||||||
|
|
||||||
|
return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip()
|
||||||
|
|||||||
@@ -289,19 +289,37 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]:
|
|||||||
branches: list[BranchInfo] = []
|
branches: list[BranchInfo] = []
|
||||||
default_branch = "main"
|
default_branch = "main"
|
||||||
|
|
||||||
|
# Get list of remote names to properly filter remote tracking branches
|
||||||
|
try:
|
||||||
|
remote_output = _run_git_command(repo_path, "remote")
|
||||||
|
remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()}
|
||||||
|
except RuntimeError:
|
||||||
|
remote_names = set()
|
||||||
|
|
||||||
for line in output.strip().split("\n"):
|
for line in output.strip().split("\n"):
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
branch_name = line.strip()
|
branch_name = line.strip()
|
||||||
# Skip remote tracking branches (they start with remotes/)
|
|
||||||
if branch_name.startswith("remotes/"):
|
# Skip detached HEAD pointer
|
||||||
# Extract just the branch name part
|
if branch_name == "HEAD":
|
||||||
parts = branch_name.split("/", 2)
|
continue
|
||||||
if len(parts) >= 3:
|
|
||||||
branch_name = parts[2]
|
# Skip remote tracking branches - they appear as "origin/branch-name"
|
||||||
else:
|
# Check if first part is a remote name
|
||||||
continue
|
if "/" in branch_name:
|
||||||
|
first_part = branch_name.split("/", 1)[0]
|
||||||
|
if first_part in remote_names:
|
||||||
|
# Extract just the branch name part (after "origin/")
|
||||||
|
branch_name = branch_name.split("/", 1)[1]
|
||||||
|
elif branch_name.startswith("remotes/"):
|
||||||
|
# Handle "remotes/origin/branch-name" format
|
||||||
|
parts = branch_name.split("/", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
branch_name = parts[2]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip duplicates
|
# Skip duplicates
|
||||||
if any(b.name == branch_name for b in branches):
|
if any(b.name == branch_name for b in branches):
|
||||||
|
|||||||
@@ -60,12 +60,9 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
|
|
||||||
Returns structured data including commits, branches, and graph information.
|
Returns structured data including commits, branches, and graph information.
|
||||||
"""
|
"""
|
||||||
# Get list of branches (may fail for empty repos)
|
# Get list of branches
|
||||||
try:
|
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
|
||||||
except RuntimeError:
|
|
||||||
branches = []
|
|
||||||
|
|
||||||
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
||||||
log_args = [
|
log_args = [
|
||||||
@@ -79,16 +76,7 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
else:
|
else:
|
||||||
log_args.append("--all")
|
log_args.append("--all")
|
||||||
|
|
||||||
try:
|
log_output = _run_git_command(repo_path, log_args)
|
||||||
log_output = _run_git_command(repo_path, log_args)
|
|
||||||
except RuntimeError:
|
|
||||||
# Empty repo or no commits
|
|
||||||
return {
|
|
||||||
"commits": [],
|
|
||||||
"branches": branches,
|
|
||||||
"total_commits": 0,
|
|
||||||
"graph_data": {"nodes": [], "edges": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get branch info for each commit
|
# Get branch info for each commit
|
||||||
branch_map = _get_branch_map(repo_path)
|
branch_map = _get_branch_map(repo_path)
|
||||||
@@ -125,11 +113,8 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get total commit count
|
# Get total commit count
|
||||||
try:
|
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
|
||||||
except RuntimeError:
|
|
||||||
total_commits = 0
|
|
||||||
|
|
||||||
# Build graph data and generate graph symbols
|
# Build graph data and generate graph symbols
|
||||||
graph_data = _build_graph_data(commits)
|
graph_data = _build_graph_data(commits)
|
||||||
|
|||||||
@@ -8,16 +8,14 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
# Set test environment BEFORE importing app modules
|
# Set test environment BEFORE importing app modules
|
||||||
os.environ["APP_ENV"] = "testing"
|
os.environ["APP_ENV"] = "testing"
|
||||||
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
from src.config import Settings, build_database_url
|
from src.config import Settings
|
||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.auth.dependencies import get_db_session
|
from src.auth.dependencies import get_db_session
|
||||||
@@ -47,10 +45,8 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||||
|
|
||||||
# Patch startup events to prevent PostgreSQL connection attempts
|
# Patch startup events to prevent PostgreSQL connection attempts
|
||||||
with patch("src.main.init_database") as mock_init, \
|
with patch("src.main.init_database") as mock_init:
|
||||||
patch("src.main.seed_builtin_tool_types") as mock_seed:
|
|
||||||
mock_init.return_value = True
|
mock_init.return_value = True
|
||||||
mock_seed.return_value = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@@ -61,6 +57,31 @@ def test_client() -> Generator[TestClient, None, None]:
|
|||||||
asyncio.run(engine.dispose())
|
asyncio.run(engine.dispose())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(test_client) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Provide an async database session for unit tests."""
|
||||||
|
# Get the override function from the test_client fixture
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
else:
|
||||||
|
# Fallback: create a new engine and session
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||||
|
yield session
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with a test user."""
|
"""Provide an authenticated test client with a test user."""
|
||||||
@@ -110,6 +131,65 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]:
|
|||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_project_and_repo(authenticated_client) -> tuple[str, str]:
|
||||||
|
"""Create a project and repository directly in the database."""
|
||||||
|
import uuid
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
repo_id = uuid.uuid4()
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
# Get user ID from session
|
||||||
|
async def get_user_id():
|
||||||
|
nonlocal user_id
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
settings = Settings()
|
||||||
|
session_cookie = authenticated_client.cookies.get("session")
|
||||||
|
if session_cookie:
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||||
|
if session:
|
||||||
|
user_id = uuid.UUID(session["user_id"])
|
||||||
|
|
||||||
|
asyncio.run(get_user_id())
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Could not get user ID from authenticated client")
|
||||||
|
|
||||||
|
async def create_project_and_repo():
|
||||||
|
override_fn = app.dependency_overrides.get(get_db_session)
|
||||||
|
if override_fn:
|
||||||
|
gen = override_fn()
|
||||||
|
session = await gen.asend(None)
|
||||||
|
try:
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name="test-project",
|
||||||
|
description="Test project",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(project)
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
id=repo_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project_id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
session.add(repo)
|
||||||
|
await session.commit()
|
||||||
|
finally:
|
||||||
|
await gen.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_project_and_repo())
|
||||||
|
|
||||||
|
return str(project_id), str(repo_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
def admin_client(test_client) -> Generator[TestClient, None, None]:
|
||||||
"""Provide an authenticated test client with an admin user."""
|
"""Provide an authenticated test client with an admin user."""
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
"""Integration tests for config profiles API."""
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -20,9 +17,7 @@ class TestConfigProfilesAPI:
|
|||||||
response = authenticated_client.get("/config-profiles")
|
response = authenticated_client.get("/config-profiles")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, list)
|
||||||
assert "profiles" in data
|
|
||||||
assert isinstance(data["profiles"], list)
|
|
||||||
|
|
||||||
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test creating a config profile."""
|
"""Test creating a config profile."""
|
||||||
@@ -31,48 +26,99 @@ class TestConfigProfilesAPI:
|
|||||||
json={
|
json={
|
||||||
"name": "test-profile",
|
"name": "test-profile",
|
||||||
"description": "Test profile",
|
"description": "Test profile",
|
||||||
|
"env_vars": {"VAR": "value"},
|
||||||
|
"runtime_hints": {"start_command": "npm start"},
|
||||||
|
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
"files": {"test.txt": "hello"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "test-profile"
|
assert data["name"] == "test-profile"
|
||||||
assert data["description"] == "Test profile"
|
assert data["env_vars"] == {"VAR": "value"}
|
||||||
|
assert data["files"] == {"test.txt": "hello"}
|
||||||
|
assert data["mounts"][0]["target"] == "/app"
|
||||||
|
|
||||||
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that duplicate profile names are rejected."""
|
"""Test that duplicate profile names are rejected."""
|
||||||
authenticated_client.post(
|
# Create first profile
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "duplicate-profile"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "duplicate-profile"},
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second with same name
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "duplicate-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that empty profile names are rejected."""
|
"""Test that profiles exceeding 10MB are rejected."""
|
||||||
|
large_content = "x" * (11 * 1024 * 1024) # 11MB
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": " "},
|
json={
|
||||||
|
"name": "large-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"large.txt": large_content},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 413
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid file paths are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {"../../../etc/passwd": "malicious"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that invalid mount targets are rejected."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "bad-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test getting a config profile by ID."""
|
"""Test getting a config profile by ID."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "get-test"},
|
json={
|
||||||
|
"name": "get-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Get it back
|
||||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "get-test"
|
assert data["name"] == "get-test"
|
||||||
assert "includes" in data
|
|
||||||
assert "mounts" in data
|
|
||||||
|
|
||||||
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test getting a non-existent profile."""
|
"""Test getting a non-existent profile."""
|
||||||
@@ -81,381 +127,327 @@ class TestConfigProfilesAPI:
|
|||||||
|
|
||||||
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test updating a config profile."""
|
"""Test updating a config profile."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "update-test"},
|
json={
|
||||||
|
"name": "update-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update it
|
||||||
response = authenticated_client.put(
|
response = authenticated_client.put(
|
||||||
f"/config-profiles/{profile_id}",
|
f"/config-profiles/{profile_id}",
|
||||||
json={"name": "updated-name", "description": "updated desc"},
|
json={
|
||||||
|
"name": "updated-name",
|
||||||
|
"env_vars": {"NEW_VAR": "new_value"},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["name"] == "updated-name"
|
assert data["name"] == "updated-name"
|
||||||
assert data["description"] == "updated desc"
|
assert data["env_vars"] == {"NEW_VAR": "new_value"}
|
||||||
|
|
||||||
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test deleting a config profile."""
|
"""Test deleting a config profile."""
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "delete-test"},
|
json={
|
||||||
|
"name": "delete-test",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Delete it
|
||||||
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
||||||
assert get_response.status_code == 404
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
def test_profile_access_check(self, authenticated_client: TestClient) -> None:
|
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test that users can only access their own profiles."""
|
"""Test updating profile includes."""
|
||||||
# Create a profile
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "base-profile",
|
||||||
|
"env_vars": {"BASE_VAR": "base_value"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "child-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Update includes
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response data: {data}")
|
||||||
|
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
|
||||||
|
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
|
||||||
|
assert data["includes"][0]["included_profile_id"] == base_id
|
||||||
|
|
||||||
|
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that include cycles are detected."""
|
||||||
|
# Create profile A
|
||||||
|
a_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-a",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
a_id = a_response.json()["id"]
|
||||||
|
|
||||||
|
# Create profile B
|
||||||
|
b_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "profile-b",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
b_id = b_response.json()["id"]
|
||||||
|
|
||||||
|
# Make B include A
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{b_id}/includes",
|
||||||
|
json={"includes": [a_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to make A include B (would create cycle)
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/config-profiles/{a_id}/includes",
|
||||||
|
json={"includes": [b_id]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test previewing a resolved config profile."""
|
||||||
|
# Create base profile
|
||||||
|
base_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-base",
|
||||||
|
"env_vars": {"BASE_VAR": "base"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
base_id = base_response.json()["id"]
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child_response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "preview-child",
|
||||||
|
"env_vars": {"CHILD_VAR": "child"},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
child_id = child_response.json()["id"]
|
||||||
|
|
||||||
|
# Make child include base
|
||||||
|
authenticated_client.put(
|
||||||
|
f"/config-profiles/{child_id}/includes",
|
||||||
|
json={"includes": [base_id]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preview child
|
||||||
|
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "preview-child"
|
||||||
|
assert data["env_vars"]["BASE_VAR"] == "base"
|
||||||
|
assert data["env_vars"]["CHILD_VAR"] == "child"
|
||||||
|
assert len(data["included_profiles"]) == 1
|
||||||
|
|
||||||
|
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile for project/tool."""
|
||||||
|
# Create a global default profile (no project/tool scoping)
|
||||||
|
authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "default-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"is_default": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve default with random project/tool (should fall back to global)
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_name"] == "default-profile"
|
||||||
|
|
||||||
|
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test resolving default profile when no profiles exist."""
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
tool_type_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
response = authenticated_client.get(
|
||||||
|
"/config-profiles/defaults/resolve",
|
||||||
|
params={"project_id": project_id, "tool_type_id": tool_type_id},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["profile_id"] is None
|
||||||
|
|
||||||
|
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test creating a config profile with git mounts."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/config-profiles",
|
||||||
|
json={
|
||||||
|
"name": "git-mount-profile",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "git-mount-profile"
|
||||||
|
assert len(data["git_mounts"]) == 1
|
||||||
|
assert data["git_mounts"][0]["target_path"] == "/app"
|
||||||
|
assert data["git_mounts"][0]["branch"] == "main"
|
||||||
|
|
||||||
|
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
|
"""Test updating git mounts on a config profile."""
|
||||||
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "access-test"},
|
json={
|
||||||
|
"name": "update-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
profile_id = create_response.json()["id"]
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
# The profile should be accessible
|
# Update with git mounts
|
||||||
response = authenticated_client.get(f"/config-profiles/{profile_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileIncludes:
|
|
||||||
"""Integration tests for config profile includes."""
|
|
||||||
|
|
||||||
def test_add_include_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding an include to a profile."""
|
|
||||||
# Create two profiles
|
|
||||||
profile1 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "profile-1"},
|
|
||||||
).json()
|
|
||||||
profile2 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "profile-2"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Add include
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile1['id']}/includes",
|
|
||||||
json={"included_profile_id": profile2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
|
||||||
assert data["included_profile_id"] == profile2["id"]
|
|
||||||
assert data["included_profile_name"] == "profile-2"
|
|
||||||
|
|
||||||
def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that self-includes are rejected."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "self-include-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/includes",
|
|
||||||
json={"included_profile_id": profile["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that circular includes are rejected."""
|
|
||||||
profile1 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "cycle-1"},
|
|
||||||
).json()
|
|
||||||
profile2 = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "cycle-2"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# Add profile1 includes profile2
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile1['id']}/includes",
|
|
||||||
json={"included_profile_id": profile2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to add profile2 includes profile1 (creates cycle)
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile2['id']}/includes",
|
|
||||||
json={"included_profile_id": profile1["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that deep circular includes are rejected."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-2"}
|
|
||||||
).json()
|
|
||||||
p3 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "deep-3"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
# p1 -> p2 -> p3
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p2['id']}/includes",
|
|
||||||
json={"included_profile_id": p3["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try p3 -> p1 (creates cycle)
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p3['id']}/includes",
|
|
||||||
json={"included_profile_id": p1["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test that duplicate includes are rejected."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "dup-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "dup-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 1},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_list_includes(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test listing includes for a profile."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "list-inc-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "list-inc-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert len(data["includes"]) == 1
|
|
||||||
|
|
||||||
def test_update_include_order(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating include order index."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "order-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "order-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
inc = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
response = authenticated_client.put(
|
||||||
f"/config-profiles/{p1['id']}/includes/{inc['id']}",
|
f"/config-profiles/{profile_id}",
|
||||||
json={"order_index": 5},
|
json={
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["order_index"] == 5
|
|
||||||
|
|
||||||
def test_remove_include(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test removing an include."""
|
|
||||||
p1 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "rem-1"}
|
|
||||||
).json()
|
|
||||||
p2 = authenticated_client.post(
|
|
||||||
"/config-profiles", json={"name": "rem-2"}
|
|
||||||
).json()
|
|
||||||
|
|
||||||
inc = authenticated_client.post(
|
|
||||||
f"/config-profiles/{p1['id']}/includes",
|
|
||||||
json={"included_profile_id": p2["id"], "order_index": 0},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-profiles/{p1['id']}/includes/{inc['id']}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileMounts:
|
|
||||||
"""Integration tests for config profile mounts."""
|
|
||||||
|
|
||||||
def test_add_mount_successfully(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test adding a mount to a profile."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0},
|
|
||||||
)
|
|
||||||
assert response.status_code == 201
|
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["target_path"] == "/etc/config"
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["files"] == {"test.txt": "hello"}
|
assert data["git_mounts"][0]["source_path"] == "config"
|
||||||
|
|
||||||
def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that relative mount paths are rejected."""
|
"""Test that invalid git mount source paths are rejected."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "rel-path-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"/config-profiles",
|
||||||
json={"target_path": "etc/config", "files": {"test.txt": "hello"}},
|
json={
|
||||||
|
"name": "bad-git-mount",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "/absolute/path",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None:
|
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that path traversal in mount paths is rejected."""
|
"""Test that git mount target paths with traversal are rejected."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "traversal-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"/config-profiles",
|
||||||
json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}},
|
json={
|
||||||
|
"name": "bad-git-mount-target",
|
||||||
|
"env_vars": {},
|
||||||
|
"files": {},
|
||||||
|
"git_mounts": [
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "../../../etc/passwd",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None:
|
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
|
||||||
"""Test that duplicate mount paths are rejected."""
|
"""Test previewing a profile with git mounts."""
|
||||||
profile = authenticated_client.post(
|
_project_id, repo_id = test_project_and_repo
|
||||||
|
|
||||||
|
# Create profile with git mounts
|
||||||
|
create_response = authenticated_client.post(
|
||||||
"/config-profiles",
|
"/config-profiles",
|
||||||
json={"name": "dup-mount-test"},
|
json={
|
||||||
).json()
|
"name": "preview-git-mounts",
|
||||||
|
"env_vars": {},
|
||||||
authenticated_client.post(
|
"files": {},
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
"git_mounts": [
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "hello"}},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
profile_id = create_response.json()["id"]
|
||||||
|
|
||||||
response = authenticated_client.post(
|
# Preview
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
|
||||||
json={"target_path": "/etc/config", "files": {"test.txt": "world"}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 409
|
|
||||||
|
|
||||||
def test_update_mount(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test updating a mount."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "update-mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
mount = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/old/path", "files": {"test.txt": "old"}},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts/{mount['id']}",
|
|
||||||
json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["target_path"] == "/new/path"
|
assert len(data["git_mounts"]) == 1
|
||||||
assert data["files"] == {"test.txt": "new"}
|
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"
|
||||||
assert data["order_index"] == 2
|
|
||||||
|
|
||||||
def test_remove_mount(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test removing a mount."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "rem-mount-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
mount = authenticated_client.post(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts",
|
|
||||||
json={"target_path": "/tmp/test", "files": {"test.txt": "x"}},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.delete(
|
|
||||||
f"/config-profiles/{profile['id']}/mounts/{mount['id']}"
|
|
||||||
)
|
|
||||||
assert response.status_code == 204
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestConfigProfileDefaults:
|
|
||||||
"""Integration tests for default profile APIs."""
|
|
||||||
|
|
||||||
def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profiles when none are set."""
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["default_profiles"] == {}
|
|
||||||
|
|
||||||
def test_set_default_profiles(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test setting default profiles."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "default-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
response = authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"code-server": profile["id"]}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["default_profiles"]["code-server"] == profile["id"]
|
|
||||||
|
|
||||||
def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test setting default profiles with invalid profile ID."""
|
|
||||||
response = authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"code-server": str(uuid.uuid4())}},
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profile for a specific tool type."""
|
|
||||||
profile = authenticated_client.post(
|
|
||||||
"/config-profiles",
|
|
||||||
json={"name": "tool-default-test"},
|
|
||||||
).json()
|
|
||||||
|
|
||||||
authenticated_client.put(
|
|
||||||
"/config-profiles/defaults",
|
|
||||||
json={"default_profiles": {"jupyter-notebook": profile["id"]}},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tool_type_id"] == "jupyter-notebook"
|
|
||||||
assert data["profile_id"] == profile["id"]
|
|
||||||
|
|
||||||
def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None:
|
|
||||||
"""Test getting default profile when not set."""
|
|
||||||
response = authenticated_client.get("/config-profiles/defaults/opencode")
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["tool_type_id"] == "opencode"
|
|
||||||
assert data["profile_id"] is None
|
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ def test_get_current_branch_handles_unborn_main() -> None:
|
|||||||
assert get_current_branch(tmpdir) == "main"
|
assert get_current_branch(tmpdir) == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
create_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_branch_on_bare_repo_with_no_commits() -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1")
|
||||||
|
checkout_branch(f"{tmpdir}/bare.git", "main")
|
||||||
|
assert get_current_branch(f"{tmpdir}/bare.git") == "main"
|
||||||
|
|
||||||
|
|
||||||
class TestBranchOperations:
|
class TestBranchOperations:
|
||||||
"""Tests for branch management functions."""
|
"""Tests for branch management functions."""
|
||||||
|
|
||||||
|
|||||||
@@ -82,28 +82,6 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
assert UserConfig.user.property.mapper.class_ is User
|
assert UserConfig.user.property.mapper.class_ is User
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
|
|
||||||
def test_refresh_token_table_has_required_columns_and_relationships() -> None:
|
|
||||||
columns = RefreshToken.__table__.columns
|
|
||||||
user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys))
|
|
||||||
|
|
||||||
assert set(columns.keys()) == {
|
|
||||||
"id",
|
|
||||||
"user_id",
|
|
||||||
"token_hash",
|
|
||||||
"expires_at",
|
|
||||||
"revoked_at",
|
|
||||||
"user_agent",
|
|
||||||
"ip_address",
|
|
||||||
"created_at",
|
|
||||||
}
|
|
||||||
assert columns["token_hash"].unique is True
|
|
||||||
assert columns["revoked_at"].nullable is True
|
|
||||||
assert user_fk.target_fullname == "users.id"
|
|
||||||
assert RefreshToken.user.property.mapper.class_ is User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ def _insert_tool_type(
|
|||||||
name: str,
|
name: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
compose_template: str,
|
compose_template: str,
|
||||||
is_builtin: bool = False,
|
|
||||||
created_by_id: str | None = None,
|
created_by_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def _run() -> None:
|
async def _run() -> None:
|
||||||
@@ -120,7 +119,6 @@ def _insert_tool_type(
|
|||||||
description="A test tool type",
|
description="A test tool type",
|
||||||
compose_template=compose_template,
|
compose_template=compose_template,
|
||||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||||
is_builtin=is_builtin,
|
|
||||||
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||||
)
|
)
|
||||||
await session.merge(tool_type)
|
await session.merge(tool_type)
|
||||||
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
|
|||||||
assert data["name"] == "my-custom-tool"
|
assert data["name"] == "my-custom-tool"
|
||||||
assert data["display_name"] == "My Custom Tool"
|
assert data["display_name"] == "My Custom Tool"
|
||||||
assert data["description"] == "A custom development tool"
|
assert data["description"] == "A custom development tool"
|
||||||
assert data["is_builtin"] == False
|
|
||||||
assert data["created_by_id"] == user_id
|
assert data["created_by_id"] == user_id
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_update_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
payload = {"display_name": "Updated"}
|
|
||||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_delete_builtin_tool_type_fails() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
||||||
_insert_user(user_id)
|
|
||||||
_insert_tool_type(
|
|
||||||
tool_type_id,
|
|
||||||
"builtin-tool",
|
|
||||||
"Built-in Tool",
|
|
||||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
|
||||||
is_builtin=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
def test_builtin_tool_types_seeded_on_startup() -> None:
|
|
||||||
_prepare_test_db()
|
|
||||||
user_id = "11111111-1111-1111-1111-111111111111"
|
|
||||||
_insert_user(user_id)
|
|
||||||
|
|
||||||
# Load app triggers startup event which seeds built-in types
|
|
||||||
app = _load_app()
|
|
||||||
client = TestClient(app)
|
|
||||||
client.cookies.set("access_token", _mint_token(user_id))
|
|
||||||
|
|
||||||
response = client.get("/tool-types")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check that built-in types exist
|
|
||||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
|
||||||
assert "code-server" in builtin_names
|
|
||||||
assert "jupyter-notebook" in builtin_names
|
|
||||||
|
|
||||||
# Verify built-in types have correct attributes
|
|
||||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
|
||||||
assert code_server is not None
|
|
||||||
assert code_server["display_name"] == "VS Code Server"
|
|
||||||
assert "services" in code_server["compose_template"]
|
|
||||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import uuid
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web"],
|
"interfaces": ["web"],
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8080",
|
"command": "curl -f http://localhost:8080",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -92,7 +91,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"display_name": "Update Test Tool",
|
"display_name": "Update Test Tool",
|
||||||
"default_port": 8080,
|
"default_port": 8080,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
"required_variables": [],
|
"required_variables": [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -167,7 +166,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web", "terminal"],
|
"interfaces": ["web", "terminal"],
|
||||||
"default_port": 8443,
|
"default_port": 8443,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8443",
|
"command": "curl -f http://localhost:8443",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -186,3 +185,114 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["category"] == "editor"
|
assert data["category"] == "editor"
|
||||||
assert data["interfaces"] == ["web", "terminal"]
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
assert "readiness_probe" in data
|
assert "readiness_probe" in data
|
||||||
|
|
||||||
|
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that creating a tool type without default_port fails validation."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "no-port-tool",
|
||||||
|
"display_name": "No Port Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "default_port" in str(data)
|
||||||
|
|
||||||
|
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that port mismatch between default_port and compose template fails."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "port-mismatch-tool",
|
||||||
|
"display_name": "Port Mismatch Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interfaces": ["web"],
|
||||||
|
"default_port": 9999,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
_ = response.json()
|
||||||
|
|
||||||
|
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test creating a tool type with startup_command."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "startup-tool",
|
||||||
|
"display_name": "Startup Tool",
|
||||||
|
"category": "utility",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "cd /workspace && ls",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "cd /workspace && ls"
|
||||||
|
assert data["interface_type"] == "terminal"
|
||||||
|
|
||||||
|
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test updating a tool type's startup_command."""
|
||||||
|
# Create tool type first
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "update-startup-tool",
|
||||||
|
"display_name": "Update Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Update with startup_command
|
||||||
|
response = authenticated_client.put(
|
||||||
|
f"/tool-types/{tool_id}",
|
||||||
|
json={
|
||||||
|
"startup_command": "source /etc/profile",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "source /etc/profile"
|
||||||
|
|
||||||
|
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
|
||||||
|
"""Test that GET returns startup_command."""
|
||||||
|
create_response = authenticated_client.post(
|
||||||
|
"/tool-types",
|
||||||
|
json={
|
||||||
|
"name": "get-startup-tool",
|
||||||
|
"display_name": "Get Startup Tool",
|
||||||
|
"interface_type": "terminal",
|
||||||
|
"requires_port": False,
|
||||||
|
"default_port": 0,
|
||||||
|
"definition_type": "compose",
|
||||||
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: alpine",
|
||||||
|
"startup_command": "echo hello",
|
||||||
|
"required_variables": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tool_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = authenticated_client.get(f"/tool-types/{tool_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["startup_command"] == "echo hello"
|
||||||
|
assert "Port 9999 is not exposed" in str(data)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
|||||||
subject=user_id,
|
subject=user_id,
|
||||||
email="test@headquarter.local",
|
email="test@headquarter.local",
|
||||||
name="Test User",
|
name="Test User",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import uuid
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
|
from src.services.config_profile_resolver import (
|
||||||
|
ConfigProfileCycleError,
|
||||||
|
ConfigProfileNotFoundError,
|
||||||
|
check_include_cycle,
|
||||||
|
resolve_profile,
|
||||||
|
_merge_env_vars,
|
||||||
|
_merge_files,
|
||||||
|
_merge_mounts,
|
||||||
|
_merge_runtime_hints,
|
||||||
|
_merge_git_mounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeFunctions:
|
||||||
|
"""Unit tests for merge helper functions."""
|
||||||
|
|
||||||
|
def test_merge_env_vars_basic(self) -> None:
|
||||||
|
"""Test basic env var merging."""
|
||||||
|
result = _merge_env_vars(
|
||||||
|
{"A": "1", "B": "2"},
|
||||||
|
{"B": "3", "C": "4"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"A": "1", "B": "3", "C": "4"}
|
||||||
|
|
||||||
|
def test_merge_env_vars_tracks_overrides(self) -> None:
|
||||||
|
"""Test that env var overrides are tracked."""
|
||||||
|
overrides = {}
|
||||||
|
_merge_env_vars(
|
||||||
|
{"A": "1"},
|
||||||
|
{"A": "2"},
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert overrides == {"A": "source"}
|
||||||
|
|
||||||
|
def test_merge_runtime_hints_basic(self) -> None:
|
||||||
|
"""Test basic runtime hint merging."""
|
||||||
|
result = _merge_runtime_hints(
|
||||||
|
{"command": "old"},
|
||||||
|
{"command": "new", "port": 8080},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"command": "new", "port": 8080}
|
||||||
|
|
||||||
|
def test_merge_files_basic(self) -> None:
|
||||||
|
"""Test basic file merging."""
|
||||||
|
result = _merge_files(
|
||||||
|
{"a.txt": "old"},
|
||||||
|
{"a.txt": "new", "b.txt": "content"},
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result == {"a.txt": "new", "b.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_basic(self) -> None:
|
||||||
|
"""Test basic mount merging."""
|
||||||
|
result = _merge_mounts(
|
||||||
|
{},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "content"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert "/app" in result
|
||||||
|
assert result["/app"].mode == "rw"
|
||||||
|
assert result["/app"].files == {"a.txt": "content"}
|
||||||
|
|
||||||
|
def test_merge_mounts_file_override(self) -> None:
|
||||||
|
"""Test mount file map merging with overrides."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||||
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||||
|
{},
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].files == {"a.txt": "new"}
|
||||||
|
|
||||||
|
def test_merge_mounts_mode_conflict(self) -> None:
|
||||||
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
overrides = {}
|
||||||
|
result = _merge_mounts(
|
||||||
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
|
[{"target": "/app", "mode": "ro", "files": {}}],
|
||||||
|
overrides,
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert result["/app"].mode == "ro"
|
||||||
|
assert overrides == {"/app": "source"}
|
||||||
|
|
||||||
|
def test_merge_git_mounts_basic(self) -> None:
|
||||||
|
"""Test basic git mount merging."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[],
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
|
assert result[0]["target_path"] == "/app"
|
||||||
|
|
||||||
|
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
||||||
|
"""Test that git mounts with same repo+target override."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["source_path"] == "src"
|
||||||
|
assert result[0]["branch"] == "dev"
|
||||||
|
|
||||||
|
def test_merge_git_mounts_different_targets(self) -> None:
|
||||||
|
"""Test that git mounts with different targets are preserved."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
||||||
|
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 2
|
||||||
|
targets = {m["target_path"] for m in result}
|
||||||
|
assert targets == {"/app", "/config"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveProfile:
|
||||||
|
"""Unit tests for profile resolution."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_simple_profile(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile with no includes."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
profile = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="simple",
|
||||||
|
env_vars={"VAR": "value"},
|
||||||
|
runtime_hints={"command": "run"},
|
||||||
|
files={"test.txt": "content"},
|
||||||
|
mounts=[{"target": "/app", "mode": "rw", "files": {}}],
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, profile.id)
|
||||||
|
assert result.profile_name == "simple"
|
||||||
|
assert result.env_vars == {"VAR": "value"}
|
||||||
|
assert result.runtime_hints == {"command": "run"}
|
||||||
|
assert result.files == {"test.txt": "content"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile that includes another."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Create base profile
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"BASE_VAR": "base_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
# Create child profile
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"CHILD_VAR": "child_value"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create include relationship
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {
|
||||||
|
"BASE_VAR": "base_value",
|
||||||
|
"CHILD_VAR": "child_value",
|
||||||
|
}
|
||||||
|
assert len(result.included_profiles) == 1
|
||||||
|
assert result.included_profiles[0]["name"] == "base"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that child profile values override parent values."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={"VAR": "base"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={"VAR": "child"},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert result.env_vars == {"VAR": "child"}
|
||||||
|
assert result.env_overrides == {"VAR": "child"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test that cycles are detected during resolution."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A (creates cycle)
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ConfigProfileCycleError):
|
||||||
|
await resolve_profile(db_session, profile_a.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile with git mounts."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="with-git-mounts",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, profile.id)
|
||||||
|
assert len(result.git_mounts) == 1
|
||||||
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
|
assert result.git_mounts[0]["target_path"] == "/app"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a profile that includes another with git mounts."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
# Create base profile with git mount
|
||||||
|
base = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="base",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(base)
|
||||||
|
|
||||||
|
# Create child profile with its own git mount
|
||||||
|
child = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="child",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
git_mounts=[
|
||||||
|
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(child)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create include relationship
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=child.id,
|
||||||
|
included_profile_id=base.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await resolve_profile(db_session, child.id)
|
||||||
|
assert len(result.git_mounts) == 2
|
||||||
|
targets = {m["target_path"] for m in result.git_mounts}
|
||||||
|
assert targets == {"/app", "/config"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test resolving a non-existent profile."""
|
||||||
|
with pytest.raises(ConfigProfileNotFoundError):
|
||||||
|
await resolve_profile(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckIncludeCycle:
|
||||||
|
"""Unit tests for include cycle checking."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_no_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test checking when no cycle exists."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_detects_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting an existing cycle."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include_ab = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ab)
|
||||||
|
|
||||||
|
# B includes A
|
||||||
|
include_ba = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_b.id,
|
||||||
|
included_profile_id=profile_a.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include_ba)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await check_include_cycle(db_session, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_would_create_cycle(self, db_session: AsyncSession) -> None:
|
||||||
|
"""Test detecting a cycle that would be created."""
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
profile_a = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="a",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_a)
|
||||||
|
|
||||||
|
profile_b = ConfigProfile(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=user_id,
|
||||||
|
name="b",
|
||||||
|
env_vars={},
|
||||||
|
files={},
|
||||||
|
)
|
||||||
|
db_session.add(profile_b)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# A includes B
|
||||||
|
include = ConfigProfileInclude(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
profile_id=profile_a.id,
|
||||||
|
included_profile_id=profile_b.id,
|
||||||
|
order_index=0,
|
||||||
|
)
|
||||||
|
db_session.add(include)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Check if adding B includes A would create cycle
|
||||||
|
result = await check_include_cycle(db_session, profile_b.id, profile_a.id)
|
||||||
|
assert result is not None
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unit tests for git mount resolution in tool instances."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import (
|
||||||
|
_checkout_branch,
|
||||||
|
_expand_glob_source,
|
||||||
|
_resolve_single_git_mount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandGlobSource:
|
||||||
|
"""Unit tests for glob pattern expansion."""
|
||||||
|
|
||||||
|
def test_no_glob_single_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob path returns single file."""
|
||||||
|
test_file = tmp_path / "test.txt"
|
||||||
|
test_file.write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(test_file), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == str(test_file)
|
||||||
|
|
||||||
|
def test_no_glob_missing_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Test non-glob missing file returns empty list."""
|
||||||
|
missing_file = tmp_path / "missing.txt"
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(missing_file), str(tmp_path))
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_glob_pattern(self, tmp_path: Path) -> None:
|
||||||
|
"""Test glob pattern matches files."""
|
||||||
|
(tmp_path / "file1.txt").write_text("content1")
|
||||||
|
(tmp_path / "file2.txt").write_text("content2")
|
||||||
|
(tmp_path / "other.py").write_text("code")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(f.endswith(".txt") for f in result)
|
||||||
|
|
||||||
|
def test_glob_recursive(self, tmp_path: Path) -> None:
|
||||||
|
"""Test recursive glob pattern."""
|
||||||
|
subdir = tmp_path / "subdir"
|
||||||
|
subdir.mkdir()
|
||||||
|
(subdir / "nested.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "**" / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "nested.txt" in result[0]
|
||||||
|
|
||||||
|
def test_glob_limit_enforced(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob matches are limited to prevent abuse."""
|
||||||
|
# Create more than 100 files
|
||||||
|
for i in range(105):
|
||||||
|
(tmp_path / f"file{i}.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path / "*.txt"), str(tmp_path))
|
||||||
|
assert len(result) == 100 # MAX_GLOB_MATCHES limit
|
||||||
|
|
||||||
|
def test_glob_escapes_repo(self, tmp_path: Path) -> None:
|
||||||
|
"""Test that glob results outside repo are filtered."""
|
||||||
|
other_dir = tmp_path.parent / "other"
|
||||||
|
other_dir.mkdir(exist_ok=True)
|
||||||
|
(other_dir / "outside.txt").write_text("content")
|
||||||
|
|
||||||
|
result = _expand_glob_source(str(tmp_path.parent / "*" / "*.txt"), str(tmp_path))
|
||||||
|
# Should only include files within tmp_path, not other_dir
|
||||||
|
assert all(r.startswith(str(tmp_path)) for r in result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckoutBranch:
|
||||||
|
"""Unit tests for branch checkout."""
|
||||||
|
|
||||||
|
def test_checkout_existing_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out an existing branch."""
|
||||||
|
# Initialize git repo
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
os.system(f"cd {tmp_path} && git branch feature")
|
||||||
|
|
||||||
|
_checkout_branch(str(tmp_path), "feature")
|
||||||
|
|
||||||
|
# Verify we're on feature branch
|
||||||
|
result = os.popen(f"cd {tmp_path} && git branch --show-current").read().strip()
|
||||||
|
assert result == "feature"
|
||||||
|
|
||||||
|
def test_checkout_nonexistent_branch(self, tmp_path: Path) -> None:
|
||||||
|
"""Test checking out a non-existent branch returns False."""
|
||||||
|
os.system(f"cd {tmp_path} && git init && git config user.email 'test@test.com' && git config user.name 'Test'")
|
||||||
|
(tmp_path / "file.txt").write_text("content")
|
||||||
|
os.system(f"cd {tmp_path} && git add . && git commit -m 'initial'")
|
||||||
|
|
||||||
|
result = _checkout_branch(str(tmp_path), "nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSingleGitMount:
|
||||||
|
"""Unit tests for resolving a single git mount."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_remote_url(self, db_session) -> None:
|
||||||
|
"""Test that missing remote_url returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_missing_target_path(self, db_session) -> None:
|
||||||
|
"""Test that missing target path returns empty list."""
|
||||||
|
git_mount = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": ".",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _resolve_single_git_mount(db_session, git_mount)
|
||||||
|
assert result == []
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for git URL parsing utilities."""
|
"""Tests for git URL parsing utilities."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url
|
||||||
|
|
||||||
|
|||||||
@@ -39,18 +39,3 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None:
|
|||||||
|
|
||||||
assert module.revision == "0002_refresh_tokens"
|
assert module.revision == "0002_refresh_tokens"
|
||||||
assert module.down_revision == "0001_initial_schema"
|
assert module.down_revision == "0001_initial_schema"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
def test_config_profiles_migration_has_expected_revision_chain() -> None:
|
|
||||||
migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py"
|
|
||||||
spec = spec_from_file_location("add_config_profiles", migration_path)
|
|
||||||
|
|
||||||
assert spec is not None
|
|
||||||
assert spec.loader is not None
|
|
||||||
|
|
||||||
module = module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
|
|
||||||
assert module.revision == "0013_add_config_profiles"
|
|
||||||
assert module.down_revision == "0012_default_port_req"
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user