From 9ad11a021c832796cfac0448ace019158652eadb Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 24 May 2026 17:58:39 +0000 Subject: [PATCH] feat: add config profiles - Add ConfigProfile and ConfigProfileInclude data models with migrations - Implement profile resolver service with ordered includes and merge rules - Add profile CRUD API with validation, compatibility, and cycle detection - Add instance API plumbing for profile selection on create/start/restart - Add resolved profile preview and default resolution APIs - Add frontend config profile API client and management UI - Add launch/restart profile selection UI - Add backend integration and unit tests (31 passing) OpenSpec: add-config-profiles Quality gates: ruff, TypeScript compile, 31 tests passing --- .stoneforge/sync/dependencies.jsonl | 42 ++ .stoneforge/sync/elements.jsonl | 3 +- .../2026_05_24_add_config_profiles.py | 86 +++ apps/api/src/api/config_profiles.py | 635 ++++++++++++++++++ apps/api/src/api/tool_instances.py | 174 ++++- apps/api/src/auth/session.py | 6 +- apps/api/src/main.py | 2 + apps/api/src/models/__init__.py | 3 +- apps/api/src/models/config_profile.py | 74 ++ apps/api/src/models/tool_instance.py | 5 + .../src/services/config_profile_resolver.py | 453 +++++++++++++ apps/api/src/services/docker.py | 53 -- apps/api/tests/conftest.py | 29 +- .../integration/test_config_profiles_api.py | 322 +++++++++ .../unit/test_config_profile_resolver.py | 383 +++++++++++ apps/web/src/api/config_profiles.ts | 145 ++++ apps/web/src/api/sessions.ts | 17 +- .../src/components/create-session-form.tsx | 56 +- apps/web/src/components/instance-list.tsx | 152 ++++- apps/web/src/pages/config-profiles.tsx | 586 ++++++++++++++++ apps/web/src/pages/settings.tsx | 3 +- apps/web/src/router.tsx | 2 + openspec/changes/add-config-profiles/tasks.md | 30 +- 23 files changed, 3136 insertions(+), 125 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_24_add_config_profiles.py create mode 100644 apps/api/src/api/config_profiles.py create mode 100644 apps/api/src/models/config_profile.py create mode 100644 apps/api/src/services/config_profile_resolver.py create mode 100644 apps/api/tests/integration/test_config_profiles_api.py create mode 100644 apps/api/tests/unit/test_config_profile_resolver.py create mode 100644 apps/web/src/api/config_profiles.ts create mode 100644 apps/web/src/pages/config-profiles.tsx diff --git a/.stoneforge/sync/dependencies.jsonl b/.stoneforge/sync/dependencies.jsonl index 56b3d12..92dcd5b 100644 --- a/.stoneforge/sync/dependencies.jsonl +++ b/.stoneforge/sync/dependencies.jsonl @@ -1 +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"} diff --git a/.stoneforge/sync/elements.jsonl b/.stoneforge/sync/elements.jsonl index 48ea31c..6e836e4 100644 --- a/.stoneforge/sync/elements.jsonl +++ b/.stoneforge/sync/elements.jsonl @@ -1 +1,2 @@ -{"id":"el-20no","type":"plan","createdAt":"2026-05-24T12:43:50.676Z","updatedAt":"2026-05-24T12:43:50.676Z","createdBy":"el-2jua","tags":["openspec","add-config-profiles"],"metadata":{},"title":"add-config-profiles","status":"active"} +{"id":"el-51a8","type":"entity","createdAt":"2026-05-24T12:03:40.486Z","updatedAt":"2026-05-24T16:40:49.450Z","createdBy":"el-0000","tags":[],"metadata":{"agent":{"agentRole":"worker","workerMode":"ephemeral","sessionStatus":"idle","provider":"opencode","model":"kimi-for-coding/k2p6","channelId":"el-9amf","lastActivityAt":"2026-05-24T16:08:30.074Z","sessionHistory":[{"id":"session-mpjy9x6g-0016-2in3","providerSessionId":"ses_1a5554e6effetx9L3jx99WwWqt","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-31-resolved-profile-preview-ap","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-31-resolved-profile-preview-ap","startedAt":"2026-05-24T15:46:36.564Z","endedAt":"2026-05-24T16:40:49.449Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxa049-000w-2z6p","providerSessionId":"ses_1a56ed6dbffetrUE0nScXrXGnA","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-31-resolved-profile-preview-ap","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-31-resolved-profile-preview-ap","startedAt":"2026-05-24T15:18:43.239Z","endedAt":"2026-05-24T15:46:11.952Z","terminationReason":"Task was reset"},{"id":"session-mpjx6aqa-000u-17we","providerSessionId":"ses_1a571872bffet73O54j3u5zdP3","status":"terminated","workingDirectory":"/home/alex/projects/headquarter","startedAt":"2026-05-24T15:15:47.032Z","endedAt":"2026-05-24T15:16:55.765Z"},{"id":"session-mpjtll8v-000i-0v89","providerSessionId":"ses_1a5cd8be6ffeh2VuHpEIx5H4RL","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:35:42.037Z","endedAt":"2026-05-24T14:06:52.035Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtl1js-000h-6094","providerSessionId":"ses_1a5cd8be6ffeh2VuHpEIx5H4RL","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:35:16.509Z","endedAt":"2026-05-24T13:35:42.041Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtjr8p-000g-zfz5","providerSessionId":"ses_1a5ce7654ffeh1JqCNXdJ3Kfl8","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:34:16.494Z","endedAt":"2026-05-24T13:35:15.970Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjthxn2-000f-bkik","providerSessionId":"ses_1a5cfc26fffeL1eTYux6WrnQd5","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:32:51.476Z","endedAt":"2026-05-24T13:34:15.970Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtgs0p-000e-hd3x","providerSessionId":"ses_1a5d09523ffeo8qbzRA7R5XoOR","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","startedAt":"2026-05-24T13:31:57.534Z","endedAt":"2026-05-24T13:32:50.964Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtfkr4-000d-xy1h","providerSessionId":"ses_1a5d17011ffe1Maf9lbbAXN6p3","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:31:01.488Z","endedAt":"2026-05-24T13:31:55.963Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtej26-000c-nkyw","providerSessionId":"ses_1a5d22efeffeZ6uY6z3C9j42bF","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","startedAt":"2026-05-24T13:30:12.612Z","endedAt":"2026-05-24T13:31:00.963Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjt44g7-0007-v1vd","providerSessionId":"ses_1a5d99775ffe6BFjKsmcKWTL09","status":"terminated","workingDirectory":"/home/alex/projects/headquarter","startedAt":"2026-05-24T13:22:07.121Z","endedAt":"2026-05-24T13:30:07.608Z"},{"id":"session-mpjt3fy3-0004-iw40","providerSessionId":"ses_1a5da10baffeDDYNRD0YpG43Ew","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-triage","startedAt":"2026-05-24T13:21:36.072Z","endedAt":"2026-05-24T13:21:50.357Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjsux8a-000b-jayr","providerSessionId":"ses_1a5e02179ffe4Qb7wnOFUwqtaC","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-22-profile-crud-validation-com","startedAt":"2026-05-24T13:14:58.569Z","endedAt":"2026-05-24T13:18:55.215Z","terminationReason":"Stopped by user via Stop All"},{"id":"session-mpjsbk4v-0007-6cbc","providerSessionId":"ses_1a5eded4effeI1mazuurCkuMfj","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","startedAt":"2026-05-24T12:59:54.420Z","endedAt":"2026-05-24T13:01:48.889Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjrteww-0005-ut10","providerSessionId":"ses_1a5fad548ffer9oUDHJyfzAG6H","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","startedAt":"2026-05-24T12:45:48.603Z","endedAt":"2026-05-24T12:59:18.882Z","terminationReason":"Process no longer alive (PID check)"}],"sessionId":"ses_1a5554e6effetx9L3jx99WwWqt"}},"name":"e-worker-1","entityType":"agent"} +{"id":"el-2i1s","type":"entity","createdAt":"2026-05-24T12:03:42.204Z","updatedAt":"2026-05-24T16:40:49.450Z","createdBy":"el-0000","tags":[],"metadata":{"agent":{"agentRole":"worker","workerMode":"ephemeral","sessionStatus":"idle","provider":"opencode","model":"kimi-for-coding/k2p6","channelId":"el-1na8","lastActivityAt":"2026-05-24T16:09:13.594Z","sessionHistory":[{"id":"session-mpjy9tfl-0015-1rfr","providerSessionId":"ses_1a5555835ffe4wit1VrAUEUryV","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:46:34.066Z","endedAt":"2026-05-24T16:40:49.449Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjy7tj0-0013-yv25","providerSessionId":"ses_1a556d0c0ffe9Pp5DzhQ3171go","status":"terminated","workingDirectory":"/home/alex/projects/headquarter","startedAt":"2026-05-24T15:44:57.666Z","endedAt":"2026-05-24T15:45:06.356Z"},{"id":"session-mpjxyncd-0012-mzl0","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:37:49.745Z","endedAt":"2026-05-24T15:38:14.160Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxy06i-0011-6s11","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:37:19.728Z","endedAt":"2026-05-24T15:37:49.749Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxxghk-0010-td7e","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:36:54.208Z","endedAt":"2026-05-24T15:37:19.730Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxrcmq-000z-5vj7","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:32:09.270Z","endedAt":"2026-05-24T15:32:33.619Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxqpgy-000y-7m71","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:31:39.255Z","endedAt":"2026-05-24T15:32:09.274Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjxq668-000x-4h7c","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T15:31:14.243Z","endedAt":"2026-05-24T15:31:39.257Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjvy2w6-000p-5lvm","providerSessionId":"ses_1a590f893ffeeXUH8RzaOpkI6O","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-52-backend-instance-launch-tes","startedAt":"2026-05-24T14:41:26.384Z","endedAt":"2026-05-24T15:31:14.249Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjugw9k-000m-ik3b","providerSessionId":"ses_1a5b6dea4ffexjf3TPdBocQiah","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-32-launch-and-restart-profile","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-32-launch-and-restart-profile","startedAt":"2026-05-24T14:00:02.657Z","endedAt":"2026-05-24T14:17:07.317Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjufb70-000l-9pnw","providerSessionId":"ses_1a5b7eac1ffecNrVHWktuqWkIY","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-32-launch-and-restart-profile","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-32-launch-and-restart-profile","startedAt":"2026-05-24T13:58:54.021Z","endedAt":"2026-05-24T14:00:02.019Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjtd0zw-000b-c0rd","providerSessionId":"ses_1a5d337c6ffe4YxBpmKWvrKCPA","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-21-profile-resolver-service","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-21-profile-resolver-service","startedAt":"2026-05-24T13:29:04.828Z","endedAt":"2026-05-24T13:54:07.001Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjt5pcg-0008-d3l9","providerSessionId":"ses_1a5e09bc1ffeUVWkhyePdcI3N2","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-23-instance-api-profile-select","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-23-instance-api-profile-select","startedAt":"2026-05-24T13:23:20.852Z","endedAt":"2026-05-24T13:29:00.960Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjt441g-0006-x97u","providerSessionId":"ses_1a5d9993affeqdurslf44QoZYl","status":"terminated","workingDirectory":"/home/alex/projects/headquarter","startedAt":"2026-05-24T13:22:07.114Z","endedAt":"2026-05-24T13:22:13.812Z"},{"id":"session-mpjt3d4a-0003-59vs","providerSessionId":"ses_1a5da1933ffemmPa8ZGZ8Q3r2D","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-triage","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-triage","startedAt":"2026-05-24T13:21:33.903Z","endedAt":"2026-05-24T13:21:53.369Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjsu7ir-0009-swhm","providerSessionId":"ses_1a5e09bc1ffeUVWkhyePdcI3N2","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-23-instance-api-profile-select","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-23-instance-api-profile-select","startedAt":"2026-05-24T13:14:27.274Z","endedAt":"2026-05-24T13:18:54.745Z","terminationReason":"Stopped by user via Stop All"},{"id":"session-mpjsahjf-0006-f0kq","providerSessionId":"ses_1a5eeb0b1ffe0xUI8Gh31D00pg","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-1-11-backend-data-model-and-migr","startedAt":"2026-05-24T12:59:04.402Z","endedAt":"2026-05-24T12:59:58.885Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjrsosk-0004-z4bb","providerSessionId":"ses_1a5fb4fc4ffe9xd4TEfuPJ1sVy","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-63-final-openspec-status-and-c","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-63-final-openspec-status-and-c","startedAt":"2026-05-24T12:45:17.252Z","endedAt":"2026-05-24T12:56:33.874Z","terminationReason":"Process no longer alive (PID check)"},{"id":"session-mpjqhtyo-0003-92yu","providerSessionId":"ses_1a61caa27ffe2trcM02SwINn30","status":"terminated","workingDirectory":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-remove-tunnel-functionality-an","worktree":"/home/alex/projects/headquarter/.stoneforge/.worktrees/e-worker-2-remove-tunnel-functionality-an","startedAt":"2026-05-24T12:08:51.424Z","endedAt":"2026-05-24T12:12:56.908Z"}],"sessionId":"ses_1a5555835ffe4wit1VrAUEUryV"}},"name":"e-worker-2","entityType":"agent"} diff --git a/apps/api/alembic/versions/2026_05_24_add_config_profiles.py b/apps/api/alembic/versions/2026_05_24_add_config_profiles.py new file mode 100644 index 0000000..fb24283 --- /dev/null +++ b/apps/api/alembic/versions/2026_05_24_add_config_profiles.py @@ -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") diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py new file mode 100644 index 0000000..aab19fb --- /dev/null +++ b/apps/api/src/api/config_profiles.py @@ -0,0 +1,635 @@ +"""Config profile API endpoints.""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.config_profile import ConfigProfile, ConfigProfileInclude +from src.models.project import Project +from src.models.tool_type import ToolType +from src.services.config_profile_resolver import ( + ConfigProfileCycleError, + check_include_cycle, + resolve_profile, + resolved_profile_to_dict, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) + +MAX_PROFILE_SIZE_MB = 10 +MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024 + + +def _validate_uuid(v: str | None) -> str | None: + if v is None: + return v + try: + uuid.UUID(v) + except ValueError: + raise ValueError(f"Invalid UUID: {v}") + return v + + +def _calculate_profile_size(data: dict) -> int: + """Calculate approximate serialized size of profile data.""" + total = 0 + for key, value in data.get("env_vars", {}).items(): + total += len(key.encode("utf-8")) + len(str(value).encode("utf-8")) + for key, value in data.get("runtime_hints", {}).items(): + total += len(key.encode("utf-8")) + len(str(value).encode("utf-8")) + for mount in data.get("mounts", []): + total += len(str(mount.get("target", "")).encode("utf-8")) + total += len(str(mount.get("mode", "")).encode("utf-8")) + for path, content in mount.get("files", {}).items(): + 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 + + +class MountItem(BaseModel): + target: str = Field(description="Absolute mount target path") + mode: str = Field(default="rw", description="Mount mode: ro or rw") + files: dict = Field(default_factory=dict, description="Files as {relative_path: content}") + + @field_validator("target") + @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 path.startswith("/") or not path: + raise ValueError(f"Invalid file path: {path}") + return v + + +class ConfigProfileCreate(BaseModel): + name: str = Field(description="Profile name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + project_id: str | None = Field(default=None, description="Optional project ID") + tool_type_id: str | None = Field(default=None, description="Optional tool type ID") + env_vars: dict = Field(default_factory=dict, description="Environment variables") + 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}") + 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 path.startswith("/") or not path: + raise ValueError(f"Invalid file path: {path}") + return v + + @field_validator("env_vars") + @classmethod + def validate_env_vars(cls, v: dict) -> dict: + if not isinstance(v, dict): + raise ValueError("env_vars must be a JSON object") + return v + + @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 + + +class ConfigProfileUpdate(BaseModel): + name: str | None = Field(default=None, description="Profile name") + description: str | None = Field(default=None, description="Optional description") + project_id: str | None = Field(default=None, description="Optional project ID") + tool_type_id: str | None = Field(default=None, description="Optional tool type ID") + env_vars: dict | None = Field(default=None, description="Environment variables") + runtime_hints: dict | None = Field(default=None, description="Runtime hints") + mounts: list[MountItem] | None = Field(default=None, description="Mount definitions") + files: dict | None = Field(default=None, description="Files as {relative_path: content}") + 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 + 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)) + ) + return result.scalar_one_or_none() + + +async def _check_access( + session: AsyncSession, + 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") + + +def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict: + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "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 [], + "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.get("", response_model=list[ConfigProfileResponse]) +async def list_config_profiles( + project_id: str | None = Query(None, description="Filter by project compatibility"), + tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"), + current_user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +): + """List config profiles, optionally filtered by compatibility.""" + user_uuid = current_user_id + query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes)) + + if project_id or tool_type_id: + # 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) + + # 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], + files=data.files, + is_default=data.is_default, + ) + session.add(profile) + 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.info("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) + + # 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() 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.info("Updated config profile %s", profile.id) + return _profile_to_response(profile) + + +@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config_profile( + profile_id: str, + current_user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_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.commit() + + logger.info("Deleted config profile %s", profile_id) + return None + + +@router.put("/{profile_id}/includes", response_model=ConfigProfileResponse) +async def update_profile_includes( + profile_id: str, + data: ConfigProfileIncludeUpdate, + current_user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +): + """Update the ordered includes for 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") + + # 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", + ) + + # Check for cycles + cycle = await check_include_cycle(session, profile.id, None) + if cycle is None and included_uuids: + # Check each new include would not create a cycle + for inc_uuid in included_uuids: + cycle = await check_include_cycle(session, profile.id, inc_uuid) + if cycle is not None: + break + + if cycle is not None: + cycle_str = " -> ".join(str(c) for c in cycle) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Include cycle detected: {cycle_str}", + ) + + # Remove existing includes + result = await session.execute( + select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id) + ) + for existing in result.scalars().all(): + await session.delete(existing) + await session.flush() + + # 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() + + await session.commit() + + # Re-fetch profile (includes loaded separately due to SQLite async issue) + result = await session.execute( + select(ConfigProfile).where(ConfigProfile.id == profile.id) + ) + profile = result.scalar_one() + + inc_result = await session.execute( + select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id) + ) + direct_includes = inc_result.scalars().all() + + logger.info("Updated includes for config profile %s", profile.id) + return _profile_to_response(profile, list(direct_includes)) + + +@router.get("/{profile_id}/preview") +async def preview_config_profile( + profile_id: str, + current_user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +): + """Preview the resolved output of 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") + + try: + resolved = await resolve_profile(session, profile.id) + except ConfigProfileCycleError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + return resolved_profile_to_dict(resolved) + + +@router.get("/defaults/resolve") +async def resolve_default_profile( + project_id: str = Query(..., description="Project ID"), + tool_type_id: str = Query(..., description="Tool type ID"), + current_user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +): + """Resolve the default config profile for a project/tool combination. + + Selects by specificity: + 1. project+tool explicit default + 2. project explicit default + 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} diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 4dd95f2..ab85435 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -20,11 +20,11 @@ from src.auth.dependencies import get_db_session from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey +from src.models.config_profile import ConfigProfile 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.models.config_folder import ConfigFolder from src.services.docker import ( check_tunnel_health, connect_container_to_network, @@ -43,10 +43,14 @@ from src.services.docker import ( write_compose_file, write_config_files, write_env_file, - write_config_folder_files, ) from src.services.clone import check_dirty_state, clone_repository, remove_clone_directory from src.services.docker_build import build_image +from src.services.config_profile_resolver import ( + ConfigProfileCycleError, + apply_resolved_profile, + resolve_profile, +) from src.services.readiness_probe import execute_probe from src.services.ssh_keys import prepare_ssh_key_files, cleanup_ssh_key_files @@ -63,6 +67,78 @@ class CreateInstanceRequest(BaseModel): clone_mode: str = Field(default="mount", description="Repository access mode: 'mount' or 'clone'") branch: str | None = Field(default="main", description="Branch to clone (when clone_mode='clone')") new_branch: str | None = Field(default=None, description="Create a new local branch after cloning") + config_profile_id: str | None = Field(default=None, description="Optional config profile ID for launch") + + +class StartInstanceRequest(BaseModel): + """Request body for starting a tool instance.""" + + model_config = {"extra": "ignore"} + + config_profile_id: str | None = Field(default=None, description="Config profile ID to apply, or null for none") + + +async def _validate_config_profile( + session: AsyncSession, + profile_id: str | None, + user_id: uuid.UUID, + project_id: uuid.UUID, + tool_type_id: uuid.UUID, +) -> uuid.UUID | None: + """Validate a config profile selection. + + Args: + session: Database session. + profile_id: Profile ID string or None. + user_id: Authenticated user ID. + project_id: Project ID for compatibility check. + tool_type_id: Tool type ID for compatibility check. + + Returns: + Validated UUID or None. + + Raises: + HTTPException: If profile is not found, not owned, or incompatible. + """ + if profile_id is None: + return None + + try: + profile_uuid = uuid.UUID(profile_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid config profile ID: {profile_id}", + ) + + profile = await session.get(ConfigProfile, profile_uuid) + if profile is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Config profile not found: {profile_id}", + ) + + if profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to use this config profile", + ) + + # Check compatibility: profile must be portable or match project/tool + is_compatible = ( + (profile.project_id is None and profile.tool_type_id is None) + or (profile.project_id == project_id) + or (profile.tool_type_id == tool_type_id) + or (profile.project_id == project_id and profile.tool_type_id == tool_type_id) + ) + + if not is_compatible: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Selected config profile is not compatible with this project and tool type", + ) + + return profile_uuid def _modify_compose_file( @@ -199,6 +275,11 @@ async def create_instance( status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" ) + # Validate config profile if provided + selected_profile_id = await _validate_config_profile( + session, data.config_profile_id, user_id, project_id, tool_type_id + ) + try: # Validate clone mode requirements if data.clone_mode == "clone": @@ -386,6 +467,7 @@ services: port=tool_port, clone_mode=data.clone_mode, branch=data.new_branch if data.new_branch else (data.branch if data.clone_mode == "clone" else None), + selected_config_profile_id=selected_profile_id, ) session.add(instance) await session.commit() @@ -399,6 +481,7 @@ services: "status": instance.status, "clone_mode": instance.clone_mode, "branch": instance.branch, + "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "created_at": instance.created_at.isoformat(), } except Exception as exc: @@ -525,6 +608,7 @@ async def get_instance( "port": instance.port, "clone_mode": instance.clone_mode, "branch": instance.branch, + "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "last_started_at": instance.last_started_at.isoformat() if instance.last_started_at else None, "last_stopped_at": instance.last_stopped_at.isoformat() if instance.last_stopped_at else None, "created_at": instance.created_at.isoformat(), @@ -540,6 +624,7 @@ async def start_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, + data: StartInstanceRequest | None = None, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: @@ -549,6 +634,7 @@ async def start_instance( project_id: UUID of the project. repo_id: UUID of the repository. instance_id: UUID of the instance to start. + data: Optional start configuration including config profile selection. user_id: ID of the authenticated user. session: Database session. @@ -564,6 +650,14 @@ async def start_instance( status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" ) + # Validate and store config profile selection + if data and data.config_profile_id is not None: + selected_profile_id = await _validate_config_profile( + session, data.config_profile_id, user_id, project_id, instance.tool_type_id + ) + instance.selected_config_profile_id = selected_profile_id + await session.commit() + 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" @@ -614,17 +708,45 @@ async def start_instance( # Merge extra env vars env_vars.update(extra_env_vars) - # Fetch active config folders for this user - folder_query = select(ConfigFolder).where( - ConfigFolder.user_id == user_id, - ConfigFolder.is_active == True, - ) - folder_result = await session.execute(folder_query) - config_folders = folder_result.scalars().all() - logger.info("Found %d active config folders for instance %s", len(config_folders), instance.id) + # Apply selected config profile if any + instance_dir = os.path.dirname(instance.compose_path) + if instance.selected_config_profile_id is not None: + try: + resolved = await resolve_profile(session, instance.selected_config_profile_id) + profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( + instance_dir, resolved + ) + # Profile env vars override tool config env vars + env_vars.update(profile_env) + # Profile files are written by apply_resolved_profile + config_files.update(profile_files) + # Profile mounts are added to extra volumes + extra_volumes.extend(profile_mounts) + # Profile runtime hints override tool config values + if profile_hints.get("start_command"): + start_command = profile_hints["start_command"] + if profile_hints.get("working_directory"): + working_directory = profile_hints["working_directory"] + if profile_hints.get("port_override"): + port_override = profile_hints["port_override"] + logger.info( + "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d)", + resolved.profile_name, + instance.id, + len(profile_env), + len(profile_files), + len(profile_mounts), + ) + except ConfigProfileCycleError as exc: + logger.error("Cycle detected in config profile for instance %s: %s", instance.id, exc) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Config profile cycle detected: {exc}", + ) + else: + logger.info("No config profile selected for instance %s", instance.id) # Write env file and config files - instance_dir = os.path.dirname(instance.compose_path) env_file_path = None if env_vars: @@ -635,12 +757,6 @@ async def start_instance( write_config_files(instance_dir, config_files) logger.info("Wrote %d config files for instance %s", len(config_files), instance.id) - # Write config folder files - if config_folders: - folder_volumes = write_config_folder_files(instance_dir, config_folders, str(project_id)) - extra_volumes.extend(folder_volumes) - logger.info("Wrote config folders with %d volume mounts for instance %s", len(folder_volumes), instance.id) - # Mount SSH key for clone-mode instances if instance.clone_mode == "clone": repo = await session.get(GitRepository, instance.repository_id) @@ -958,7 +1074,30 @@ async def restart_instance( except Exception as exc: logger.warning("Failed to stop old tunnel for instance %s: %s", instance.id, exc) + # Re-apply stored config profile on restart if instance.compose_path and os.path.exists(instance.compose_path): + instance_dir = os.path.dirname(instance.compose_path) + if instance.selected_config_profile_id is not None: + try: + resolved = await resolve_profile(session, instance.selected_config_profile_id) + profile_env, profile_files, profile_mounts, profile_hints = apply_resolved_profile( + instance_dir, resolved + ) + # Write env file with resolved profile env vars + if profile_env: + write_env_file(instance_dir, profile_env) + logger.info( + "Re-applied config profile %s on restart for instance %s", + resolved.profile_name, + instance.id, + ) + except ConfigProfileCycleError as exc: + logger.error( + "Cycle detected in stored config profile for instance %s: %s", + instance.id, + exc, + ) + returncode, stdout, stderr = execute_compose_command( instance.compose_path, "restart" ) @@ -1483,6 +1622,7 @@ async def get_user_sessions( "url": instance.url, "clone_mode": instance.clone_mode, "branch": instance.branch, + "selected_config_profile_id": str(instance.selected_config_profile_id) if instance.selected_config_profile_id else None, "created_at": instance.created_at.isoformat() if instance.created_at else None, }) diff --git a/apps/api/src/auth/session.py b/apps/api/src/auth/session.py index 67f5bed..6ab3c0f 100644 --- a/apps/api/src/auth/session.py +++ b/apps/api/src/auth/session.py @@ -2,7 +2,7 @@ import hmac import hashlib import json import base64 -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from src.config import Settings @@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str: """Create a signed session cookie value.""" payload = { "user_id": user_id, - "exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()), + "exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()), } header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode()) @@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str, payload = json.loads(payload_bytes) # Check expiry - if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()): + if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()): raise ValueError("session expired") return payload diff --git a/apps/api/src/main.py b/apps/api/src/main.py index fa3372d..bfb3b44 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router from src.api.instance_proxy import router as instance_proxy_router from src.api.config_folders import router as config_folders_router +from src.api.config_profiles import router as config_profiles_router from src.api.tool_configs import router as tool_configs_router from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import sessions_router @@ -125,6 +126,7 @@ app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) app.include_router(config_folders_router) +app.include_router(config_profiles_router) app.include_router(tool_instances_router) app.include_router(tool_configs_router) app.include_router(sessions_router) diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 38599b6..4426212 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,5 +1,6 @@ from src.models.base import Base from src.models.config_folder import ConfigFolder +from src.models.config_profile import ConfigProfile, ConfigProfileInclude from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey @@ -8,4 +9,4 @@ from src.models.tool_type import ToolType from src.models.user import User from src.models.user_config import UserConfig -__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] +__all__ = ["Base", "ConfigFolder", "ConfigProfile", "ConfigProfileInclude", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py new file mode 100644 index 0000000..a806ff2 --- /dev/null +++ b/apps/api/src/models/config_profile.py @@ -0,0 +1,74 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean +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.project import Project + from src.models.tool_type import ToolType + from src.models.user import User + + +class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_profiles" + + user_id: Mapped[uuid.UUID] = mapped_column( + 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( + UUID(), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True + ) + tool_type_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("tool_types.id", ondelete="CASCADE"), nullable=True + ) + env_vars: Mapped[dict] = mapped_column( + JSON, default=dict, nullable=False + ) # {"VAR_NAME": "value", ...} + runtime_hints: Mapped[dict] = mapped_column( + JSON, default=dict, nullable=False + ) # {"start_command": "...", "working_dir": "...", ...} + mounts: Mapped[list] = mapped_column( + JSON, default=list, nullable=False + ) # [{"target": "/path", "mode": "rw", "files": {"rel/path": "content"}}, ...] + files: Mapped[dict] = mapped_column( + JSON, default=dict, nullable=False + ) # {"rel/path": "content", ...} + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user: Mapped["User"] = relationship() + project: Mapped["Project | None"] = relationship() + tool_type: Mapped["ToolType | None"] = relationship() + includes: Mapped[list["ConfigProfileInclude"]] = relationship( + "ConfigProfileInclude", + foreign_keys="ConfigProfileInclude.profile_id", + order_by="ConfigProfileInclude.order_index", + cascade="all, delete-orphan", + ) + + +class ConfigProfileInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_profile_includes" + + 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], + ) diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index 8715558..589ff9b 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -9,6 +9,7 @@ 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 from src.models.git_repository import GitRepository from src.models.project import Project from src.models.tool_type import ToolType @@ -71,8 +72,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): 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 + ) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() project: Mapped["Project"] = relationship() owner: Mapped["User"] = relationship() + selected_config_profile: Mapped["ConfigProfile | None"] = relationship() diff --git a/apps/api/src/services/config_profile_resolver.py b/apps/api/src/services/config_profile_resolver.py new file mode 100644 index 0000000..77ba8e8 --- /dev/null +++ b/apps/api/src/services/config_profile_resolver.py @@ -0,0 +1,453 @@ +"""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) + 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 + + +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, + ) + + # 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, + ) + + 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, + }, + "included_profiles": resolved.included_profiles, + } diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 083ed57..edf4f80 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -92,59 +92,6 @@ def write_config_files(instance_dir: str, files: dict[str, str]) -> None: 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 - - def execute_compose_command( compose_path: str, action: str, timeout: int = 60, env_file: str | None = None ) -> tuple[int, str, str]: diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index a940330..a1fe920 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -47,10 +47,8 @@ def test_client() -> Generator[TestClient, None, None]: app.dependency_overrides[get_db_session] = override_get_db_session # Patch startup events to prevent PostgreSQL connection attempts - with patch("src.main.init_database") as mock_init, \ - patch("src.main.seed_builtin_tool_types") as mock_seed: + with patch("src.main.init_database") as mock_init: mock_init.return_value = True - mock_seed.return_value = None try: with TestClient(app) as client: @@ -61,6 +59,31 @@ def test_client() -> Generator[TestClient, None, None]: 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 def authenticated_client(test_client) -> Generator[TestClient, None, None]: """Provide an authenticated test client with a test user.""" diff --git a/apps/api/tests/integration/test_config_profiles_api.py b/apps/api/tests/integration/test_config_profiles_api.py new file mode 100644 index 0000000..860ea52 --- /dev/null +++ b/apps/api/tests/integration/test_config_profiles_api.py @@ -0,0 +1,322 @@ +import uuid +import pytest +from fastapi.testclient import TestClient + + +@pytest.mark.integration +class TestConfigProfilesAPI: + """Integration tests for config profiles API.""" + + def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None: + """Test that listing config profiles requires authentication.""" + response = test_client.get("/config-profiles") + assert response.status_code == 401 + + def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None: + """Test that authenticated users can list their profiles.""" + response = authenticated_client.get("/config-profiles") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test creating a config profile.""" + response = authenticated_client.post( + "/config-profiles", + json={ + "name": "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 + data = response.json() + assert data["name"] == "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: + """Test that duplicate profile names are rejected.""" + # Create first profile + response = authenticated_client.post( + "/config-profiles", + 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 + + def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None: + """Test that profiles exceeding 10MB are rejected.""" + large_content = "x" * (11 * 1024 * 1024) # 11MB + response = authenticated_client.post( + "/config-profiles", + 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 + + def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None: + """Test getting a config profile by ID.""" + # Create profile first + create_response = authenticated_client.post( + "/config-profiles", + json={ + "name": "get-test", + "env_vars": {}, + "files": {}, + }, + ) + profile_id = create_response.json()["id"] + + # Get it back + response = authenticated_client.get(f"/config-profiles/{profile_id}") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "get-test" + + def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None: + """Test getting a non-existent profile.""" + response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}") + assert response.status_code == 404 + + def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test updating a config profile.""" + # Create profile first + create_response = authenticated_client.post( + "/config-profiles", + json={ + "name": "update-test", + "env_vars": {}, + "files": {}, + }, + ) + profile_id = create_response.json()["id"] + + # Update it + response = authenticated_client.put( + f"/config-profiles/{profile_id}", + json={ + "name": "updated-name", + "env_vars": {"NEW_VAR": "new_value"}, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "updated-name" + assert data["env_vars"] == {"NEW_VAR": "new_value"} + + def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test deleting a config profile.""" + # Create profile first + create_response = authenticated_client.post( + "/config-profiles", + json={ + "name": "delete-test", + "env_vars": {}, + "files": {}, + }, + ) + profile_id = create_response.json()["id"] + + # Delete it + response = authenticated_client.delete(f"/config-profiles/{profile_id}") + assert response.status_code == 204 + + # Verify it's gone + get_response = authenticated_client.get(f"/config-profiles/{profile_id}") + assert get_response.status_code == 404 + + def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None: + """Test updating profile includes.""" + # 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 diff --git a/apps/api/tests/unit/test_config_profile_resolver.py b/apps/api/tests/unit/test_config_profile_resolver.py new file mode 100644 index 0000000..25d9731 --- /dev/null +++ b/apps/api/tests/unit/test_config_profile_resolver.py @@ -0,0 +1,383 @@ +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, + ResolvedProfile, + check_include_cycle, + resolve_profile, + _merge_env_vars, + _merge_files, + _merge_mounts, + _merge_runtime_hints, +) + + +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.""" + from src.services.config_profile_resolver import ResolvedMount + 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"} + + +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_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 diff --git a/apps/web/src/api/config_profiles.ts b/apps/web/src/api/config_profiles.ts new file mode 100644 index 0000000..15cfb10 --- /dev/null +++ b/apps/web/src/api/config_profiles.ts @@ -0,0 +1,145 @@ +import { apiClient } from "./client"; + +export interface ConfigProfile { + id: string; + user_id: string; + name: string; + description: string | null; + project_id: string | null; + tool_type_id: string | null; + env_vars: Record; + runtime_hints: Record; + mounts: ConfigProfileMount[]; + files: Record; + is_default: boolean; + includes: ConfigProfileInclude[]; + created_at: string; + updated_at: string; +} + +export interface ConfigProfileMount { + target: string; + mode: "ro" | "rw"; + files: Record; +} + +export interface ConfigProfileInclude { + id: string; + included_profile_id: string; + order_index: number; +} + +export interface ResolvedProfile { + profile_id: string; + profile_name: string; + env_vars: Record; + runtime_hints: Record; + mounts: ResolvedMount[]; + files: Record; + overrides: { + env_vars: Record; + runtime_hints: Record; + files: Record; + mounts: Record; + }; + included_profiles: Array<{ id: string; name: string }>; +} + +export interface ResolvedMount { + target: string; + mode: "ro" | "rw"; + files: Record; + overridden_files: Record; +} + +export interface CreateConfigProfileRequest { + name: string; + description?: string; + project_id?: string; + tool_type_id?: string; + env_vars?: Record; + runtime_hints?: Record; + mounts?: ConfigProfileMount[]; + files?: Record; + is_default?: boolean; +} + +export interface UpdateConfigProfileRequest { + name?: string; + description?: string; + project_id?: string; + tool_type_id?: string; + env_vars?: Record; + runtime_hints?: Record; + mounts?: ConfigProfileMount[]; + files?: Record; + is_default?: boolean; +} + +export interface UpdateIncludesRequest { + includes: string[]; +} + +export const listConfigProfiles = async ( + projectId?: string, + toolTypeId?: string +): Promise => { + const response = await apiClient.get("/config-profiles", { + params: { project_id: projectId, tool_type_id: toolTypeId }, + }); + return response.data; +}; + +export const getConfigProfile = async (id: string): Promise => { + const response = await apiClient.get(`/config-profiles/${id}`); + return response.data; +}; + +export const createConfigProfile = async ( + data: CreateConfigProfileRequest +): Promise => { + const response = await apiClient.post("/config-profiles", data); + return response.data; +}; + +export const updateConfigProfile = async ( + id: string, + data: UpdateConfigProfileRequest +): Promise => { + const response = await apiClient.put(`/config-profiles/${id}`, data); + return response.data; +}; + +export const deleteConfigProfile = async (id: string): Promise => { + await apiClient.delete(`/config-profiles/${id}`); +}; + +export const updateProfileIncludes = async ( + id: string, + data: UpdateIncludesRequest +): Promise => { + const response = await apiClient.put( + `/config-profiles/${id}/includes`, + data + ); + return response.data; +}; + +export const previewConfigProfile = async ( + id: string +): Promise => { + const response = await apiClient.get( + `/config-profiles/${id}/preview` + ); + return response.data; +}; + +export const resolveDefaultProfile = async ( + projectId: string, + toolTypeId: string +): Promise<{ profile_id: string | null; profile_name: string | null }> => { + const response = await apiClient.get("/config-profiles/defaults/resolve", { + params: { project_id: projectId, tool_type_id: toolTypeId }, + }); + return response.data; +}; diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index ae552d6..6fe0ae4 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -10,6 +10,7 @@ export interface ToolInstance { status: string; url: string | null; port: number | null; + selected_config_profile_id: string | null; created_at: string; } @@ -49,7 +50,8 @@ export async function createInstance( displayName?: string, cloneMode?: string, branch?: string, - newBranch?: string + newBranch?: string, + configProfileId?: string ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, @@ -59,6 +61,7 @@ export async function createInstance( clone_mode: cloneMode || "mount", branch: branch || undefined, new_branch: newBranch || undefined, + config_profile_id: configProfileId, } ); return response.data; @@ -67,10 +70,12 @@ export async function createInstance( export async function startInstance( projectId: string, repoId: string, - instanceId: string + instanceId: string, + configProfileId?: string ): Promise<{ status: string; url?: string }> { const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start` + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, + { config_profile_id: configProfileId } ); return response.data; } @@ -89,10 +94,12 @@ export async function stopInstance( export async function restartInstance( projectId: string, repoId: string, - instanceId: string + instanceId: string, + configProfileId?: string ): Promise<{ status: string; url?: string }> { const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart` + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, + { config_profile_id: configProfileId } ); return response.data; } diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx index 1be7f3a..4305e2a 100644 --- a/apps/web/src/components/create-session-form.tsx +++ b/apps/web/src/components/create-session-form.tsx @@ -5,6 +5,7 @@ import type { Project } from "../types"; import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories"; import type { ToolType } from "../api/tool_types"; import { listSSHKeys, type SSHKey } from "../api/ssh_keys"; +import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles"; interface CreateSessionFormProps { projects: Project[]; @@ -46,6 +47,8 @@ export const CreateSessionForm = ({ const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); const [branch, setBranch] = useState("main"); const [sshKeys, setSshKeys] = useState([]); + const [configProfiles, setConfigProfiles] = useState([]); + const [selectedConfigProfile, setSelectedConfigProfile] = useState(""); const [branches, setBranches] = useState([]); const [isLoadingBranches, setIsLoadingBranches] = useState(false); @@ -71,6 +74,30 @@ export const CreateSessionForm = ({ void loadKeys(); }, [showCloneMode]); + // Load config profiles when tool type is selected + useEffect(() => { + const projectId = fixedProjectId || selectedProject; + if (!selectedToolType || !projectId) { + setConfigProfiles([]); + setSelectedConfigProfile(""); + return; + } + const loadProfiles = async () => { + try { + const profiles = await listConfigProfiles(projectId, selectedToolType); + setConfigProfiles(profiles); + // Auto-select default if available + const defaultProfile = profiles.find((p) => p.is_default); + if (defaultProfile) { + setSelectedConfigProfile(defaultProfile.id); + } + } catch { + // ignore + } + }; + void loadProfiles(); + }, [selectedToolType, selectedProject, fixedProjectId]); + // Load branches when selected repo changes useEffect(() => { const projectId = fixedProjectId || selectedProject; @@ -138,7 +165,8 @@ export const CreateSessionForm = ({ : undefined, showCloneMode && cloneMode === "clone" && isCreatingNewBranch ? newBranchName - : undefined + : undefined, + selectedConfigProfile || undefined ); setProgress("Starting container..."); @@ -298,8 +326,26 @@ export const CreateSessionForm = ({ )} - {/* Step 4: Clone Mode & Branch */} - {showCloneMode && hasToolType && renderStep("Repository Access", 4, true, false, + {/* Step 4: Config Profile */} + {hasToolType && renderStep("Config Profile (optional)", 4, true, false, + + )} + + {/* Step 5: Clone Mode & Branch */} + {showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
+ {instance.selected_config_profile_id && ( +
+ + Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id} + +
+ )}
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && ( @@ -236,14 +262,57 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp )} {instance.status !== "running" && ( - + <> + {profileSelectInstanceId === instance.id ? ( +
+ + + +
+ ) : ( + + )} + )} {instance.status === "running" && ( <> @@ -274,13 +343,54 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp )} - + {profileSelectInstanceId === instance.id ? ( +
+ + + +
+ ) : ( + + )} )} +
+ + {error &&
{error}
} + + {!showForm && ( + + )} + + {showForm && ( +
+

{editingProfile ? "Edit Profile" : "Create Profile"}

+ +
+ + updateFormField("name", e.target.value)} + placeholder="e.g., Development Environment" + required + /> +
+ +
+ + updateFormField("description", e.target.value || undefined)} + placeholder="Optional description" + /> +
+ +
+ + updateFormField("project_id", e.target.value || undefined)} + placeholder="Optional project UUID" + /> +
+ +
+ + updateFormField("tool_type_id", e.target.value || undefined)} + placeholder="Optional tool type UUID" + /> +
+ +
+ +
+ +
+

Environment Variables

+ {Object.entries(formData.env_vars || {}).map(([key, value]) => ( +
+ updateEnvVar(key, e.target.value, value)} + placeholder="VAR_NAME" + /> + updateEnvVar(key, key, e.target.value)} + placeholder="value" + /> + +
+ ))} + +
+ +
+

Runtime Hints

+