From b39d6ce5f41d21e0659a1d937991973ceee1c15d Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 3 Jun 2026 08:51:02 +0000 Subject: [PATCH] merge: align dev branch with main --- .gitignore | 9 +- AGENTS.md | 30 - CHANGELOG.md | 1 + apps/api/Dockerfile | 5 +- .../versions/0013_add_config_profiles.py | 104 + .../0014_add_profile_resolver_fields.py | 119 + apps/api/src/api/__init__.py | 4 +- apps/api/src/api/auth.py | 20 +- apps/api/src/api/config_folders.py | 284 ++ apps/api/src/api/config_profiles.py | 1191 ++----- apps/api/src/api/git_repositories.py | 1621 +-------- apps/api/src/api/health.py | 50 +- apps/api/src/api/instance_proxy.py | 1 + apps/api/src/api/projects.py | 146 +- apps/api/src/api/ssh_keys.py | 123 +- apps/api/src/api/terminal.py | 691 +--- apps/api/src/api/tool_configs.py | 213 ++ apps/api/src/api/tool_instances.py | 2997 +---------------- apps/api/src/api/tool_types.py | 427 +-- apps/api/src/api/user_config.py | 56 +- apps/api/src/api/users.py | 68 +- apps/api/src/auth/dependencies.py | 28 +- apps/api/src/logging_config.py | 49 +- apps/api/src/main.py | 55 +- apps/api/src/models/__init__.py | 19 +- apps/api/src/models/config_folder.py | 33 + apps/api/src/models/config_include.py | 36 + apps/api/src/models/config_mount.py | 31 + apps/api/src/models/config_profile.py | 70 +- apps/api/src/models/git_repository.py | 7 +- apps/api/src/models/tool_config.py | 48 + apps/api/src/models/tool_instance.py | 53 +- apps/api/src/models/tool_type.py | 24 +- apps/api/src/models/user_config.py | 20 + apps/api/src/schemas/__init__.py | 1 + apps/api/src/schemas/config_folder.py | 44 + apps/api/src/schemas/config_profile.py | 131 + apps/api/src/schemas/git_repository.py | 129 + apps/api/src/schemas/health.py | 50 + apps/api/src/schemas/project.py | 25 + apps/api/src/schemas/ssh_key.py | 16 + apps/api/src/schemas/tool_config.py | 47 + apps/api/src/schemas/tool_instance.py | 41 + apps/api/src/schemas/tool_type.py | 204 ++ apps/api/src/schemas/user.py | 19 + apps/api/src/schemas/user_config.py | 21 + apps/api/src/seeds/__init__.py | 0 apps/api/src/seeds/builtin_tool_types.py | 161 + apps/api/src/services/config_profiles.py | 299 ++ apps/api/src/services/docker/__init__.py | 44 + apps/api/src/services/docker/compose.py | 237 ++ .../api/src/services/docker/config_staging.py | 79 + apps/api/src/services/docker/container.py | 121 + apps/api/src/services/docker/tunnel.py | 146 + apps/api/src/services/docker_build.py | 43 +- apps/api/src/services/git/__init__.py | 1 + apps/api/src/services/git/control.py | 196 ++ apps/api/src/services/git/files.py | 150 + apps/api/src/services/git/repository.py | 211 ++ apps/api/src/services/instance_lifecycle.py | 420 +++ apps/api/src/services/profile_resolver.py | 251 ++ apps/api/src/services/terminal_manager.py | 523 +-- apps/api/src/services/terminal_session.py | 465 +-- apps/api/src/utils/git_control.py | 28 +- apps/api/src/utils/git_files.py | 34 +- apps/api/src/utils/git_history.py | 27 +- apps/api/tests/conftest.py | 92 +- .../integration/test_config_folders_api.py | 255 ++ .../integration/test_config_profiles_api.py | 662 ++-- .../api/tests/integration/test_git_control.py | 14 - apps/api/tests/integration/test_models.py | 45 +- .../tests/integration/test_projects_api.py | 5 +- .../test_tool_configs_api_extended.py | 256 ++ .../tests/integration/test_tool_types_api.py | 77 +- .../test_tool_types_api_extended.py | 164 +- apps/api/tests/integration/test_users_api.py | 4 +- apps/api/tests/unit/test_git_url_parser.py | 1 + .../api/tests/unit/test_migration_metadata.py | 15 + apps/api/tests/unit/test_profile_resolver.py | 463 +++ apps/api/tests/unit/test_readiness_probe.py | 2 + apps/api/tests/unit/test_terminal_manager.py | 112 + apps/api/tests/unit/test_terminal_session.py | 168 + apps/web/nginx.conf | 6 - apps/web/package-lock.json | 93 +- apps/web/package.json | 4 +- apps/web/scripts/check-structure.js | 83 + apps/web/src/api/client.ts | 29 +- apps/web/src/api/config-folders.test.ts | 131 + apps/web/src/api/config-folders.ts | 77 + apps/web/src/api/git-repositories.ts | 191 ++ apps/web/src/api/projects.ts | 51 +- apps/web/src/api/sessions.ts | 124 +- apps/web/src/api/settings.ts | 36 +- apps/web/src/api/ssh-keys.ts | 26 + apps/web/src/api/tool-configs.ts | 52 + apps/web/src/api/tool-types.test.ts | 227 ++ apps/web/src/api/tool-types.ts | 51 + .../src/components/ProtectedRoute.test.tsx | 49 + apps/web/src/components/ProtectedRoute.tsx | 19 + .../features/dashboard/ActiveSessionsList.tsx | 86 + .../features/dashboard/DashboardSummary.tsx | 34 + .../features/dashboard/ProjectsSection.tsx | 40 + .../features/dashboard/QuickCreateForm.tsx | 129 + .../dashboard/RecentSessionsSection.tsx | 47 + .../components/features/dashboard/index.ts | 5 + .../features/git/CommitDialog.module.css | 130 + .../components/features/git/CommitDialog.tsx | 160 + .../features/git/CommitPanel.module.css | 84 + .../components/features/git/CommitPanel.tsx | 103 + .../features/git/FileBrowser.module.css | 63 + .../features/git/FileBrowser.test.tsx | 62 + .../components/features/git/FileBrowser.tsx | 150 + .../features/git/FileEditor.module.css | 28 + .../components/features/git/FileEditor.tsx | 305 ++ .../features/git/FileViewer.module.css | 45 + .../features/git/GitToolbar.module.css | 170 + .../components/features/git/GitToolbar.tsx | 269 ++ .../features/git/MergeDialog.module.css | 45 + .../components/features/git/MergeDialog.tsx | 145 + .../features/git/SyntaxHighlighter.tsx | 77 + .../features/git/WorkspaceSidebar.tsx | 64 + apps/web/src/components/features/git/index.ts | 2 + .../project/RepositoriesSettingsTab.test.tsx | 114 + .../project/RepositoriesSettingsTab.tsx | 101 + .../project/RepositoryCreateDialog.tsx | 326 ++ .../session/CreateSessionForm.test.tsx | 147 + .../features/session/CreateSessionForm.tsx | 167 + .../features/session/InstanceList.module.css | 73 + .../features/session/InstanceList.tsx | 392 +++ .../features/session/SessionCard.test.tsx | 76 + .../features/session/SessionCard.tsx | 211 ++ .../features/session/SessionList.tsx | 194 ++ .../src/components/features/session/index.ts | 3 + .../settings/SettingsTabLayout.module.css | 89 + .../features/settings/SettingsTabLayout.tsx | 46 + .../terminal/TerminalComponent.module.css | 142 + .../features/terminal/TerminalComponent.tsx | 310 ++ .../features/tool-configs/ToolConfigForm.tsx | 129 + .../features/tool-configs/ToolConfigList.tsx | 84 + .../components/features/tool-configs/index.ts | 2 + .../features/tool-types/ToolTypeForm.tsx | 205 ++ .../features/tool-types/ToolTypeList.tsx | 86 + .../components/features/tool-types/index.ts | 2 + .../tool-workshop/ConfigFoldersTab.tsx | 289 ++ .../features/tool-workshop/ToolConfigsTab.tsx | 460 +++ .../tool-workshop/ToolTypesTab.test.tsx | 60 + .../features/tool-workshop/ToolTypesTab.tsx | 543 +++ .../features/tool-workshop/index.ts | 3 + .../features/workspace/WorkspaceHeader.tsx | 48 + .../src/components/layout/AppShell.module.css | 147 + apps/web/src/components/layout/AppShell.tsx | 137 + apps/web/src/components/ui/CodeEditor.tsx | 56 + apps/web/src/components/ui/ConfirmDialog.tsx | 40 + .../web/src/components/ui/ErrorState.test.tsx | 22 + apps/web/src/components/ui/ErrorState.tsx | 17 + apps/web/src/components/ui/Icon.tsx | 167 + .../src/components/ui/LoadingState.test.tsx | 15 + apps/web/src/components/ui/LoadingState.tsx | 9 + apps/web/src/components/ui/StatusBadge.tsx | 9 + apps/web/src/components/ui/index.ts | 5 + apps/web/src/hooks/use-dashboard-actions.ts | 121 + .../src/hooks/use-terminal-connection.test.ts | 339 ++ apps/web/src/hooks/use-terminal-connection.ts | 439 +++ apps/web/src/main.tsx | 30 +- apps/web/src/pages/DashboardPage.test.tsx | 81 + apps/web/src/pages/DashboardPage.tsx | 193 ++ apps/web/src/pages/GitHistoryPage.tsx | 233 ++ apps/web/src/pages/GitRepositoriesPage.tsx | 152 + apps/web/src/pages/PlaceholderPage.tsx | 37 + apps/web/src/pages/ProfilePage.tsx | 194 ++ apps/web/src/pages/ProjectSettingsPage.tsx | 148 + apps/web/src/pages/ProjectsPage.test.tsx | 205 ++ apps/web/src/pages/ProjectsPage.tsx | 206 ++ apps/web/src/pages/RepoWorkspacePage.tsx | 209 ++ apps/web/src/pages/SessionsPage.tsx | 214 ++ apps/web/src/pages/SettingsPage.tsx | 166 + apps/web/src/pages/SshKeysPage.tsx | 142 + apps/web/src/pages/TerminalPage.tsx | 38 + apps/web/src/pages/ToolConfigsPage.tsx | 205 ++ apps/web/src/pages/ToolTypesPage.tsx | 143 + apps/web/src/pages/ToolWorkshopPage.test.tsx | 527 +++ apps/web/src/pages/ToolWorkshopPage.tsx | 77 + apps/web/src/router.tsx | 54 +- apps/web/src/state/sessions.tsx | 59 +- apps/web/src/styles/global.css | 127 + apps/web/src/styles/pages/dashboard.css | 51 + apps/web/src/styles/pages/git-history.css | 255 ++ apps/web/src/styles/pages/projects.css | 33 + apps/web/src/styles/pages/repo-workspace.css | 142 + apps/web/src/styles/pages/sessions.css | 176 + apps/web/src/styles/pages/settings.css | 40 + apps/web/src/styles/pages/ssh-keys.css | 66 + apps/web/src/styles/syntax-highlight.css | 131 + apps/web/src/styles/tokens.css | 69 + apps/web/src/styles/utilities.css | 718 ++++ apps/web/src/types.ts | 46 +- apps/web/src/types/api-response.ts | 10 + apps/web/src/types/config-folder.ts | 36 + apps/web/src/types/git-repository.ts | 85 + apps/web/src/types/index.ts | 29 + apps/web/src/types/project.ts | 7 + apps/web/src/types/session.ts | 13 + apps/web/src/types/terminal.ts | 82 + apps/web/src/types/tool-config.ts | 28 + apps/web/src/types/tool-instance.ts | 12 + apps/web/src/types/tool-type.ts | 54 + apps/web/src/types/user.ts | 10 + apps/web/src/utils/icons.ts | 181 +- apps/web/src/utils/terminal-protocol.ts | 76 + apps/web/tsconfig.json | 9 +- apps/web/vite.config.ts | 22 +- docker-compose.traefik.yml | 13 +- docker-compose.yml | 21 +- docs/README.md | 1 + docs/api/README.md | 2 +- docs/api/config-profiles.md | 536 ++- docs/api/repositories.md | 67 - docs/architecture/backend.md | 124 +- docs/architecture/frontend.md | 90 +- docs/development/naming.md | 172 + docs/features/projects.md | 21 +- docs/features/terminal.md | 248 +- docs/features/tool-types.md | 11 - .../.openspec.yaml | 2 + .../design.md | 47 + .../proposal.md | 27 + .../specs/frontend-foundation/spec.md | 19 + .../specs/project-management/spec.md | 37 + .../tasks.md | 36 + .../.openspec.yaml | 2 + .../cloudflare-tunnel-instances/design.md | 142 + .../cloudflare-tunnel-instances/proposal.md | 31 + .../cloudflare-tunnel-management/spec.md | 50 + .../cloudflare-tunnel-instances/tasks.md | 45 + .../git-repo-ssh-clone-check/.openspec.yaml | 2 + .../git-repo-ssh-clone-check/design.md | 45 + .../git-repo-ssh-clone-check/proposal.md | 25 + .../changes/git-repo-ssh-clone-check/tasks.md | 22 + .../changes/git-repo-working-clones/design.md | 33 + .../git-repo-working-clones/proposal.md | 17 + .../changes/git-repo-working-clones/tasks.md | 19 + .../changes/instance-proxy/.openspec.yaml | 2 + openspec/changes/instance-proxy/design.md | 83 + openspec/changes/instance-proxy/proposal.md | 28 + .../specs/instance-proxy/spec.md | 41 + openspec/changes/instance-proxy/tasks.md | 26 + .../opencode-web-terminal/.openspec.yaml | 2 + .../changes/opencode-web-terminal/design.md | 71 + .../changes/opencode-web-terminal/proposal.md | 29 + .../specs/opencode-web-server/spec.md | 39 + .../specs/tool-port-configuration/spec.md | 38 + .../specs/tool-types/spec.md | 48 + .../changes/opencode-web-terminal/tasks.md | 39 + .../repo-restructure/apply-1.1-report.md | 53 + .../repo-restructure/apply-1.2-report.md | 32 + .../repo-restructure/apply-2.1-report.md | 67 + .../repo-restructure/apply-2.2-report.md | 34 + .../repo-restructure/apply-2.3-report.md | 52 + .../repo-restructure/apply-3.1-report.md | 39 + .../repo-restructure/apply-3.2-report.md | 53 + .../repo-restructure/apply-3.4-report.md | 57 + .../repo-restructure/apply-3.5-report.md | 62 + .../repo-restructure/apply-4.1-report.md | 56 + .../repo-restructure/apply-4.2-report.md | 48 + .../repo-restructure/apply-4.3-report.md | 67 + .../repo-restructure/apply-4.4-report.md | 83 + .../repo-restructure/apply-5.1-report.md | 34 + .../repo-restructure/apply-5.2-report.md | 80 + openspec/changes/repo-restructure/design.md | 723 ++++ openspec/changes/repo-restructure/explore.md | 532 +++ openspec/changes/repo-restructure/proposal.md | 172 + openspec/changes/repo-restructure/spec.md | 329 ++ openspec/changes/repo-restructure/tasks.md | 675 ++++ .../changes/responsive-terminal/design.md | 371 ++ .../changes/responsive-terminal/explore.md | 59 + .../changes/responsive-terminal/proposal.md | 77 + openspec/changes/responsive-terminal/spec.md | 153 + openspec/changes/responsive-terminal/tasks.md | 213 ++ .../changes/responsive-terminal/verify.md | 67 + .../session-management-fixes/.openspec.yaml | 2 + .../session-management-fixes/design.md | 62 + .../session-management-fixes/proposal.md | 27 + .../specs/session-lifecycle-ux/spec.md | 34 + .../specs/tool-instances/spec.md | 46 + .../specs/tunnel-health-monitoring/spec.md | 35 + .../changes/session-management-fixes/tasks.md | 42 + .../sessions-hub/.openspec/config.yaml | 2 + openspec/changes/sessions-hub/design.md | 120 + openspec/changes/sessions-hub/proposal.md | 51 + openspec/changes/sessions-hub/specs/spec.md | 115 + openspec/changes/sessions-hub/tasks.md | 93 + .../tool-config-management/.openspec.yaml | 2 + .../changes/tool-config-management/design.md | 65 + .../tool-config-management/proposal.md | 26 + .../specs/tool-config-management/spec.md | 46 + .../changes/tool-config-management/tasks.md | 42 + .../tool-config-ui-rework/.openspec.yaml | 2 + .../changes/tool-config-ui-rework/design.md | 39 + .../changes/tool-config-ui-rework/proposal.md | 28 + .../specs/tool-config-management/spec.md | 53 + .../changes/tool-config-ui-rework/tasks.md | 59 + openspec/changes/tool-workshop/design.md | 379 +++ openspec/changes/tool-workshop/proposal.md | 57 + .../tool-workshop/specs/config-folders.md | 113 + .../tool-workshop/specs/readiness-probes.md | 163 + .../tool-workshop/specs/tool-workshop.md | 96 + openspec/changes/tool-workshop/tasks.md | 204 ++ .../ui-redesign-home-settings/.openspec.yaml | 2 + .../ui-redesign-home-settings/design.md | 106 + .../ui-redesign-home-settings/proposal.md | 35 + .../ui-redesign-home-settings/tasks.md | 34 + openspec/specs/frontend-foundation/spec.md | 41 +- openspec/specs/git-repo/spec.md | 21 +- openspec/specs/project-management/spec.md | 33 +- openspec/specs/tool-instances/spec.md | 163 +- openspec/specs/tool-terminal/spec.md | 19 - openspec/specs/tool-types-definition/spec.md | 48 +- openspec/specs/tool-types/spec.md | 53 +- progress.md | 73 + 319 files changed, 30221 insertions(+), 9221 deletions(-) create mode 100644 apps/api/alembic/versions/0013_add_config_profiles.py create mode 100644 apps/api/alembic/versions/0014_add_profile_resolver_fields.py create mode 100644 apps/api/src/api/config_folders.py create mode 100644 apps/api/src/api/tool_configs.py create mode 100644 apps/api/src/models/config_folder.py create mode 100644 apps/api/src/models/config_include.py create mode 100644 apps/api/src/models/config_mount.py create mode 100644 apps/api/src/models/tool_config.py create mode 100644 apps/api/src/schemas/__init__.py create mode 100644 apps/api/src/schemas/config_folder.py create mode 100644 apps/api/src/schemas/config_profile.py create mode 100644 apps/api/src/schemas/git_repository.py create mode 100644 apps/api/src/schemas/health.py create mode 100644 apps/api/src/schemas/project.py create mode 100644 apps/api/src/schemas/ssh_key.py create mode 100644 apps/api/src/schemas/tool_config.py create mode 100644 apps/api/src/schemas/tool_instance.py create mode 100644 apps/api/src/schemas/tool_type.py create mode 100644 apps/api/src/schemas/user.py create mode 100644 apps/api/src/schemas/user_config.py create mode 100644 apps/api/src/seeds/__init__.py create mode 100644 apps/api/src/seeds/builtin_tool_types.py create mode 100644 apps/api/src/services/config_profiles.py create mode 100644 apps/api/src/services/docker/__init__.py create mode 100644 apps/api/src/services/docker/compose.py create mode 100644 apps/api/src/services/docker/config_staging.py create mode 100644 apps/api/src/services/docker/container.py create mode 100644 apps/api/src/services/docker/tunnel.py create mode 100644 apps/api/src/services/git/__init__.py create mode 100644 apps/api/src/services/git/control.py create mode 100644 apps/api/src/services/git/files.py create mode 100644 apps/api/src/services/git/repository.py create mode 100644 apps/api/src/services/instance_lifecycle.py create mode 100644 apps/api/src/services/profile_resolver.py create mode 100644 apps/api/tests/integration/test_config_folders_api.py create mode 100644 apps/api/tests/integration/test_tool_configs_api_extended.py create mode 100644 apps/api/tests/unit/test_profile_resolver.py create mode 100644 apps/api/tests/unit/test_terminal_manager.py create mode 100644 apps/api/tests/unit/test_terminal_session.py create mode 100644 apps/web/scripts/check-structure.js create mode 100644 apps/web/src/api/config-folders.test.ts create mode 100644 apps/web/src/api/config-folders.ts create mode 100644 apps/web/src/api/git-repositories.ts create mode 100644 apps/web/src/api/ssh-keys.ts create mode 100644 apps/web/src/api/tool-configs.ts create mode 100644 apps/web/src/api/tool-types.test.ts create mode 100644 apps/web/src/api/tool-types.ts create mode 100644 apps/web/src/components/ProtectedRoute.test.tsx create mode 100644 apps/web/src/components/ProtectedRoute.tsx create mode 100644 apps/web/src/components/features/dashboard/ActiveSessionsList.tsx create mode 100644 apps/web/src/components/features/dashboard/DashboardSummary.tsx create mode 100644 apps/web/src/components/features/dashboard/ProjectsSection.tsx create mode 100644 apps/web/src/components/features/dashboard/QuickCreateForm.tsx create mode 100644 apps/web/src/components/features/dashboard/RecentSessionsSection.tsx create mode 100644 apps/web/src/components/features/dashboard/index.ts create mode 100644 apps/web/src/components/features/git/CommitDialog.module.css create mode 100644 apps/web/src/components/features/git/CommitDialog.tsx create mode 100644 apps/web/src/components/features/git/CommitPanel.module.css create mode 100644 apps/web/src/components/features/git/CommitPanel.tsx create mode 100644 apps/web/src/components/features/git/FileBrowser.module.css create mode 100644 apps/web/src/components/features/git/FileBrowser.test.tsx create mode 100644 apps/web/src/components/features/git/FileBrowser.tsx create mode 100644 apps/web/src/components/features/git/FileEditor.module.css create mode 100644 apps/web/src/components/features/git/FileEditor.tsx create mode 100644 apps/web/src/components/features/git/FileViewer.module.css create mode 100644 apps/web/src/components/features/git/GitToolbar.module.css create mode 100644 apps/web/src/components/features/git/GitToolbar.tsx create mode 100644 apps/web/src/components/features/git/MergeDialog.module.css create mode 100644 apps/web/src/components/features/git/MergeDialog.tsx create mode 100644 apps/web/src/components/features/git/SyntaxHighlighter.tsx create mode 100644 apps/web/src/components/features/git/WorkspaceSidebar.tsx create mode 100644 apps/web/src/components/features/git/index.ts create mode 100644 apps/web/src/components/features/project/RepositoriesSettingsTab.test.tsx create mode 100644 apps/web/src/components/features/project/RepositoriesSettingsTab.tsx create mode 100644 apps/web/src/components/features/project/RepositoryCreateDialog.tsx create mode 100644 apps/web/src/components/features/session/CreateSessionForm.test.tsx create mode 100644 apps/web/src/components/features/session/CreateSessionForm.tsx create mode 100644 apps/web/src/components/features/session/InstanceList.module.css create mode 100644 apps/web/src/components/features/session/InstanceList.tsx create mode 100644 apps/web/src/components/features/session/SessionCard.test.tsx create mode 100644 apps/web/src/components/features/session/SessionCard.tsx create mode 100644 apps/web/src/components/features/session/SessionList.tsx create mode 100644 apps/web/src/components/features/session/index.ts create mode 100644 apps/web/src/components/features/settings/SettingsTabLayout.module.css create mode 100644 apps/web/src/components/features/settings/SettingsTabLayout.tsx create mode 100644 apps/web/src/components/features/terminal/TerminalComponent.module.css create mode 100644 apps/web/src/components/features/terminal/TerminalComponent.tsx create mode 100644 apps/web/src/components/features/tool-configs/ToolConfigForm.tsx create mode 100644 apps/web/src/components/features/tool-configs/ToolConfigList.tsx create mode 100644 apps/web/src/components/features/tool-configs/index.ts create mode 100644 apps/web/src/components/features/tool-types/ToolTypeForm.tsx create mode 100644 apps/web/src/components/features/tool-types/ToolTypeList.tsx create mode 100644 apps/web/src/components/features/tool-types/index.ts create mode 100644 apps/web/src/components/features/tool-workshop/ConfigFoldersTab.tsx create mode 100644 apps/web/src/components/features/tool-workshop/ToolConfigsTab.tsx create mode 100644 apps/web/src/components/features/tool-workshop/ToolTypesTab.test.tsx create mode 100644 apps/web/src/components/features/tool-workshop/ToolTypesTab.tsx create mode 100644 apps/web/src/components/features/tool-workshop/index.ts create mode 100644 apps/web/src/components/features/workspace/WorkspaceHeader.tsx create mode 100644 apps/web/src/components/layout/AppShell.module.css create mode 100644 apps/web/src/components/layout/AppShell.tsx create mode 100644 apps/web/src/components/ui/CodeEditor.tsx create mode 100644 apps/web/src/components/ui/ConfirmDialog.tsx create mode 100644 apps/web/src/components/ui/ErrorState.test.tsx create mode 100644 apps/web/src/components/ui/ErrorState.tsx create mode 100644 apps/web/src/components/ui/Icon.tsx create mode 100644 apps/web/src/components/ui/LoadingState.test.tsx create mode 100644 apps/web/src/components/ui/LoadingState.tsx create mode 100644 apps/web/src/components/ui/StatusBadge.tsx create mode 100644 apps/web/src/components/ui/index.ts create mode 100644 apps/web/src/hooks/use-dashboard-actions.ts create mode 100644 apps/web/src/hooks/use-terminal-connection.test.ts create mode 100644 apps/web/src/hooks/use-terminal-connection.ts create mode 100644 apps/web/src/pages/DashboardPage.test.tsx create mode 100644 apps/web/src/pages/DashboardPage.tsx create mode 100644 apps/web/src/pages/GitHistoryPage.tsx create mode 100644 apps/web/src/pages/GitRepositoriesPage.tsx create mode 100644 apps/web/src/pages/PlaceholderPage.tsx create mode 100644 apps/web/src/pages/ProfilePage.tsx create mode 100644 apps/web/src/pages/ProjectSettingsPage.tsx create mode 100644 apps/web/src/pages/ProjectsPage.test.tsx create mode 100644 apps/web/src/pages/ProjectsPage.tsx create mode 100644 apps/web/src/pages/RepoWorkspacePage.tsx create mode 100644 apps/web/src/pages/SessionsPage.tsx create mode 100644 apps/web/src/pages/SettingsPage.tsx create mode 100644 apps/web/src/pages/SshKeysPage.tsx create mode 100644 apps/web/src/pages/TerminalPage.tsx create mode 100644 apps/web/src/pages/ToolConfigsPage.tsx create mode 100644 apps/web/src/pages/ToolTypesPage.tsx create mode 100644 apps/web/src/pages/ToolWorkshopPage.test.tsx create mode 100644 apps/web/src/pages/ToolWorkshopPage.tsx create mode 100644 apps/web/src/styles/global.css create mode 100644 apps/web/src/styles/pages/dashboard.css create mode 100644 apps/web/src/styles/pages/git-history.css create mode 100644 apps/web/src/styles/pages/projects.css create mode 100644 apps/web/src/styles/pages/repo-workspace.css create mode 100644 apps/web/src/styles/pages/sessions.css create mode 100644 apps/web/src/styles/pages/settings.css create mode 100644 apps/web/src/styles/pages/ssh-keys.css create mode 100644 apps/web/src/styles/syntax-highlight.css create mode 100644 apps/web/src/styles/tokens.css create mode 100644 apps/web/src/styles/utilities.css create mode 100644 apps/web/src/types/api-response.ts create mode 100644 apps/web/src/types/config-folder.ts create mode 100644 apps/web/src/types/git-repository.ts create mode 100644 apps/web/src/types/index.ts create mode 100644 apps/web/src/types/project.ts create mode 100644 apps/web/src/types/session.ts create mode 100644 apps/web/src/types/terminal.ts create mode 100644 apps/web/src/types/tool-config.ts create mode 100644 apps/web/src/types/tool-instance.ts create mode 100644 apps/web/src/types/tool-type.ts create mode 100644 apps/web/src/types/user.ts create mode 100644 apps/web/src/utils/terminal-protocol.ts create mode 100644 docs/development/naming.md create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/design.md create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/proposal.md create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/specs/frontend-foundation/spec.md create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/specs/project-management/spec.md create mode 100644 openspec/changes/archive/2026-05-22-add-project-settings-page/tasks.md create mode 100644 openspec/changes/cloudflare-tunnel-instances/.openspec.yaml create mode 100644 openspec/changes/cloudflare-tunnel-instances/design.md create mode 100644 openspec/changes/cloudflare-tunnel-instances/proposal.md create mode 100644 openspec/changes/cloudflare-tunnel-instances/specs/cloudflare-tunnel-management/spec.md create mode 100644 openspec/changes/cloudflare-tunnel-instances/tasks.md create mode 100644 openspec/changes/git-repo-ssh-clone-check/.openspec.yaml create mode 100644 openspec/changes/git-repo-ssh-clone-check/design.md create mode 100644 openspec/changes/git-repo-ssh-clone-check/proposal.md create mode 100644 openspec/changes/git-repo-ssh-clone-check/tasks.md create mode 100644 openspec/changes/git-repo-working-clones/design.md create mode 100644 openspec/changes/git-repo-working-clones/proposal.md create mode 100644 openspec/changes/git-repo-working-clones/tasks.md create mode 100644 openspec/changes/instance-proxy/.openspec.yaml create mode 100644 openspec/changes/instance-proxy/design.md create mode 100644 openspec/changes/instance-proxy/proposal.md create mode 100644 openspec/changes/instance-proxy/specs/instance-proxy/spec.md create mode 100644 openspec/changes/instance-proxy/tasks.md create mode 100644 openspec/changes/opencode-web-terminal/.openspec.yaml create mode 100644 openspec/changes/opencode-web-terminal/design.md create mode 100644 openspec/changes/opencode-web-terminal/proposal.md create mode 100644 openspec/changes/opencode-web-terminal/specs/opencode-web-server/spec.md create mode 100644 openspec/changes/opencode-web-terminal/specs/tool-port-configuration/spec.md create mode 100644 openspec/changes/opencode-web-terminal/specs/tool-types/spec.md create mode 100644 openspec/changes/opencode-web-terminal/tasks.md create mode 100644 openspec/changes/repo-restructure/apply-1.1-report.md create mode 100644 openspec/changes/repo-restructure/apply-1.2-report.md create mode 100644 openspec/changes/repo-restructure/apply-2.1-report.md create mode 100644 openspec/changes/repo-restructure/apply-2.2-report.md create mode 100644 openspec/changes/repo-restructure/apply-2.3-report.md create mode 100644 openspec/changes/repo-restructure/apply-3.1-report.md create mode 100644 openspec/changes/repo-restructure/apply-3.2-report.md create mode 100644 openspec/changes/repo-restructure/apply-3.4-report.md create mode 100644 openspec/changes/repo-restructure/apply-3.5-report.md create mode 100644 openspec/changes/repo-restructure/apply-4.1-report.md create mode 100644 openspec/changes/repo-restructure/apply-4.2-report.md create mode 100644 openspec/changes/repo-restructure/apply-4.3-report.md create mode 100644 openspec/changes/repo-restructure/apply-4.4-report.md create mode 100644 openspec/changes/repo-restructure/apply-5.1-report.md create mode 100644 openspec/changes/repo-restructure/apply-5.2-report.md create mode 100644 openspec/changes/repo-restructure/design.md create mode 100644 openspec/changes/repo-restructure/explore.md create mode 100644 openspec/changes/repo-restructure/proposal.md create mode 100644 openspec/changes/repo-restructure/spec.md create mode 100644 openspec/changes/repo-restructure/tasks.md create mode 100644 openspec/changes/responsive-terminal/design.md create mode 100644 openspec/changes/responsive-terminal/explore.md create mode 100644 openspec/changes/responsive-terminal/proposal.md create mode 100644 openspec/changes/responsive-terminal/spec.md create mode 100644 openspec/changes/responsive-terminal/tasks.md create mode 100644 openspec/changes/responsive-terminal/verify.md create mode 100644 openspec/changes/session-management-fixes/.openspec.yaml create mode 100644 openspec/changes/session-management-fixes/design.md create mode 100644 openspec/changes/session-management-fixes/proposal.md create mode 100644 openspec/changes/session-management-fixes/specs/session-lifecycle-ux/spec.md create mode 100644 openspec/changes/session-management-fixes/specs/tool-instances/spec.md create mode 100644 openspec/changes/session-management-fixes/specs/tunnel-health-monitoring/spec.md create mode 100644 openspec/changes/session-management-fixes/tasks.md create mode 100644 openspec/changes/sessions-hub/.openspec/config.yaml create mode 100644 openspec/changes/sessions-hub/design.md create mode 100644 openspec/changes/sessions-hub/proposal.md create mode 100644 openspec/changes/sessions-hub/specs/spec.md create mode 100644 openspec/changes/sessions-hub/tasks.md create mode 100644 openspec/changes/tool-config-management/.openspec.yaml create mode 100644 openspec/changes/tool-config-management/design.md create mode 100644 openspec/changes/tool-config-management/proposal.md create mode 100644 openspec/changes/tool-config-management/specs/tool-config-management/spec.md create mode 100644 openspec/changes/tool-config-management/tasks.md create mode 100644 openspec/changes/tool-config-ui-rework/.openspec.yaml create mode 100644 openspec/changes/tool-config-ui-rework/design.md create mode 100644 openspec/changes/tool-config-ui-rework/proposal.md create mode 100644 openspec/changes/tool-config-ui-rework/specs/tool-config-management/spec.md create mode 100644 openspec/changes/tool-config-ui-rework/tasks.md create mode 100644 openspec/changes/tool-workshop/design.md create mode 100644 openspec/changes/tool-workshop/proposal.md create mode 100644 openspec/changes/tool-workshop/specs/config-folders.md create mode 100644 openspec/changes/tool-workshop/specs/readiness-probes.md create mode 100644 openspec/changes/tool-workshop/specs/tool-workshop.md create mode 100644 openspec/changes/tool-workshop/tasks.md create mode 100644 openspec/changes/ui-redesign-home-settings/.openspec.yaml create mode 100644 openspec/changes/ui-redesign-home-settings/design.md create mode 100644 openspec/changes/ui-redesign-home-settings/proposal.md create mode 100644 openspec/changes/ui-redesign-home-settings/tasks.md create mode 100644 progress.md diff --git a/.gitignore b/.gitignore index d10ca6a..1be2f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,8 @@ apps/web/dist/ # OS .DS_Store Thumbs.db -/.stoneforge/.worktrees/ -# Pi / agent cache -.pi/ + +# Local runtime state .atl/ -.sisyphus/ -.pi-lens/ +.pi/ +swap-pane diff --git a/AGENTS.md b/AGENTS.md index 5047fef..3d0a94b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,6 @@ OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified. -## Communication - -All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language. - ## Priority order 1. Current user instruction @@ -75,7 +71,6 @@ Do not: * Introduce new dependencies without clear justification. * Treat existing code as more authoritative than OpenSpec for intended behavior. * Decide product behavior silently when the spec is unclear. -* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first. If scope must change, propose an OpenSpec update first. @@ -92,31 +87,6 @@ Do not claim completion without verification evidence. ## Git workflow -### Branching strategy - -For every spec change or new functionality: - -1. Create a new branch from `dev` with a proper prefix: - - `feat/` for new features (e.g., `feat/tool-workshop`) - - `fix/` for bug fixes (e.g., `fix/terminal-tty`) - - `refactor/` for refactors (e.g., `refactor/api-cleanup`) - - `docs/` for documentation (e.g., `docs/api-guide`) - - `chore/` for maintenance (e.g., `chore/update-deps`) -2. Branch name should reference the OpenSpec change name when applicable. -3. Do not commit directly to `main` or `dev`. - -### Completion and merge - -When implementation is complete and verified: - -1. Ensure all tests pass and quality gates are met. -2. Stage all changes with `git add -A`. -3. Create a commit with a proper conventional commit message (see below). -4. Switch to `dev`: `git checkout dev`. -5. Merge the feature branch: `git merge --no-ff `. -6. Push to remote: `git push origin dev`. -7. Delete the local feature branch if desired: `git branch -d `. - ### Auto-commit on spec completion When an OpenSpec change is fully implemented and all tasks are complete: diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f2077..30d55a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **User Settings** - Theme selection, git identity, and preference management - **SSH Key Management** - Ed25519 key generation with secure storage - **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support +- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection - **Comprehensive Documentation** - Architecture, API, deployment, and development guides ### Changed diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index fdbfe39..f6ceaf8 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -27,7 +27,6 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq5 \ git \ - openssh-client \ netcat-openbsd \ ca-certificates \ curl \ @@ -50,8 +49,8 @@ ENV PATH=/root/.local/bin:$PATH # Copy application code COPY --chown=appuser:appgroup . . -# Create directories for repo, instance, and workspace storage -RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data +# Create directories for repo and instance storage +RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data # Copy wait-for-db script COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh diff --git a/apps/api/alembic/versions/0013_add_config_profiles.py b/apps/api/alembic/versions/0013_add_config_profiles.py new file mode 100644 index 0000000..d042419 --- /dev/null +++ b/apps/api/alembic/versions/0013_add_config_profiles.py @@ -0,0 +1,104 @@ +"""add config profiles, includes, mounts, and tool instance profile selection + +Revision ID: 0013_add_config_profiles +Revises: 0012_default_port_req +Create Date: 2026-05-24 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0013_add_config_profiles" +down_revision: Union[str, None] = "0012_default_port_req" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create config_profiles table + op.create_table( + "config_profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), + ) + op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"]) + + # Create config_includes table + op.create_table( + "config_includes", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"), + ) + op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"]) + op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"]) + + # Create config_mounts table + op.create_table( + "config_mounts", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("mount_path", sa.String(length=1024), nullable=False), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"]) + + # Add selected_profile_id to tool_instances + op.add_column( + "tool_instances", + sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_tool_instances_selected_profile", + "tool_instances", + "config_profiles", + ["selected_profile_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"]) + + +def downgrade() -> None: + # Remove selected_profile_id from tool_instances + op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances") + op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey") + op.drop_column("tool_instances", "selected_profile_id") + + # Drop config_mounts + op.drop_index("idx_config_mounts_profile", table_name="config_mounts") + op.drop_table("config_mounts") + + # Drop config_includes + op.drop_index("idx_config_includes_included", table_name="config_includes") + op.drop_index("idx_config_includes_profile", table_name="config_includes") + op.drop_table("config_includes") + + # Drop config_profiles + op.drop_index("idx_config_profiles_user", table_name="config_profiles") + op.drop_table("config_profiles") diff --git a/apps/api/alembic/versions/0014_add_profile_resolver_fields.py b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py new file mode 100644 index 0000000..8d923fb --- /dev/null +++ b/apps/api/alembic/versions/0014_add_profile_resolver_fields.py @@ -0,0 +1,119 @@ +"""add profile resolver fields to config profiles and mounts + +Revision ID: 0014_add_profile_resolver_fields +Revises: 0013_add_config_profiles +Create Date: 2026-05-24 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0014_add_profile_resolver_fields" +down_revision: Union[str, None] = "0013_add_config_profiles" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add fields to config_profiles + op.add_column( + "config_profiles", + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("tool_type_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("environment_variables", sa.JSON(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("start_command", sa.Text(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("working_directory", sa.Text(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("port", sa.Integer(), nullable=True), + ) + op.add_column( + "config_profiles", + sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"), + ) + + # Add foreign keys for project and tool_type + op.create_foreign_key( + "fk_config_profiles_project", + "config_profiles", + "projects", + ["project_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_foreign_key( + "fk_config_profiles_tool_type", + "config_profiles", + "tool_types", + ["tool_type_id"], + ["id"], + ondelete="CASCADE", + ) + + # Create indices + op.create_index("idx_config_profiles_project", "config_profiles", ["project_id"]) + op.create_index("idx_config_profiles_tool_type", "config_profiles", ["tool_type_id"]) + + # Alter config_mounts: rename mount_path to target_path, add mode, change content to files JSON + op.alter_column("config_mounts", "mount_path", new_column_name="target_path") + op.add_column( + "config_mounts", + sa.Column("mode", sa.String(length=10), nullable=False, server_default="rw"), + ) + op.add_column( + "config_mounts", + sa.Column("files", sa.JSON(), nullable=True), + ) + # Drop the source_profile foreign key if it exists + op.drop_constraint( + "config_mounts_source_profile_id_fkey", + "config_mounts", + type_="foreignkey", + ) + op.drop_column("config_mounts", "content") + op.drop_column("config_mounts", "source_profile_id") + + +def downgrade() -> None: + # Restore config_mounts + op.add_column( + "config_mounts", + sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.add_column( + "config_mounts", + sa.Column("content", sa.Text(), nullable=True), + ) + op.drop_column("config_mounts", "files") + op.drop_column("config_mounts", "mode") + op.alter_column("config_mounts", "target_path", new_column_name="mount_path") + + # Restore config_profiles + op.drop_index("idx_config_profiles_tool_type", table_name="config_profiles") + op.drop_index("idx_config_profiles_project", table_name="config_profiles") + op.drop_constraint("fk_config_profiles_tool_type", "config_profiles", type_="foreignkey") + op.drop_constraint("fk_config_profiles_project", "config_profiles", type_="foreignkey") + op.drop_column("config_profiles", "is_default") + op.drop_column("config_profiles", "port") + op.drop_column("config_profiles", "working_directory") + op.drop_column("config_profiles", "start_command") + op.drop_column("config_profiles", "environment_variables") + op.drop_column("config_profiles", "tool_type_id") + op.drop_column("config_profiles", "project_id") diff --git a/apps/api/src/api/__init__.py b/apps/api/src/api/__init__.py index ccb9492..15fea80 100644 --- a/apps/api/src/api/__init__.py +++ b/apps/api/src/api/__init__.py @@ -1,6 +1,4 @@ from src.api.auth import router as auth_router -from src.api.events import router as events_router -from src.api.notifications import router as notifications_router from src.api.users import router as users_router -__all__ = ["auth_router", "events_router", "notifications_router", "users_router"] +__all__ = ["auth_router", "users_router"] diff --git a/apps/api/src/api/auth.py b/apps/api/src/api/auth.py index d83a2cf..a3bcc42 100644 --- a/apps/api/src/api/auth.py +++ b/apps/api/src/api/auth.py @@ -48,7 +48,7 @@ async def login(next: str = "/") -> RedirectResponse: redirect_uri=redirect_uri, state=state, ) - logger.debug("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next) + logger.info("Auth login initiated: redirect_uri=%s, next=%s", redirect_uri, next) response = RedirectResponse(location) response.set_cookie("auth_state", state, httponly=True, samesite="lax") response.set_cookie("auth_next", next, httponly=True, samesite="lax") @@ -63,7 +63,7 @@ async def callback( auth_next: str | None = Cookie(default="/"), session: AsyncSession = Depends(get_db_session), ) -> RedirectResponse: - logger.debug("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None") + logger.info("Auth callback received: code=%s... state=%s", code[:10] if code else "None", state[:10] if state else "None") if auth_state is None or auth_state != state: logger.warning("State mismatch: cookie=%s, param=%s", auth_state, state) @@ -71,7 +71,7 @@ async def callback( settings = Settings() redirect_uri = f"{settings.api_base_url}/auth/callback" - logger.debug("Exchanging code for tokens (redirect_uri=%s)", redirect_uri) + logger.info("Exchanging code for tokens (redirect_uri=%s)", redirect_uri) async with httpx.AsyncClient() as client: try: @@ -92,7 +92,7 @@ async def callback( access_token=token_payload["access_token"], client=client, ) - logger.debug("User info fetched successfully") + logger.info("User info fetched successfully") except Exception as exc: logger.error("User info fetch failed: %s", exc) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info") @@ -100,19 +100,19 @@ async def callback( authentik_id = str(user_info.get("sub", "")) email = str(user_info.get("email", f"{authentik_id}@authentik.local")) name = str(user_info.get("name", email)) - logger.debug("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name) + logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name) try: user = await session.scalar(select(User).where(User.authentik_id == authentik_id)) if user is None: - logger.debug("Creating new user: authentik_id=%s", authentik_id) + logger.info("Creating new user: authentik_id=%s", authentik_id) user = User(email=email, name=name, authentik_id=authentik_id, avatar_url=None) session.add(user) await session.commit() await session.refresh(user) logger.info("New user created: id=%s", user.id) else: - logger.debug("Existing user found: id=%s, updating info", user.id) + logger.info("Existing user found: id=%s, updating info", user.id) user.email = email user.name = name await session.commit() @@ -165,20 +165,20 @@ async def me( session_cookie: str | None = Cookie(default=None, alias="session"), session: AsyncSession = Depends(get_db_session), ) -> dict[str, Any]: - logger.debug("Auth /me called, cookie present: %s", bool(session_cookie)) + logger.info("Auth /me called, cookie present: %s", bool(session_cookie)) if not session_cookie: logger.warning("Auth /me: missing session cookie") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session") settings = Settings() - logger.debug("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s", + logger.info("Auth /me: cookie_domain=%s, cookie_secure=%s, cookie_samesite=%s", settings.cookie_domain, settings.cookie_secure, settings.cookie_samesite) try: payload = decode_session_cookie(settings=settings, cookie_value=session_cookie) user_id = payload["user_id"] - logger.debug("Auth /me: decoded session for user_id=%s", user_id) + logger.info("Auth /me: decoded session for user_id=%s", user_id) except ValueError as exc: logger.warning("Auth /me: invalid session: %s", exc) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) diff --git a/apps/api/src/api/config_folders.py b/apps/api/src/api/config_folders.py new file mode 100644 index 0000000..ebdf7c8 --- /dev/null +++ b/apps/api/src/api/config_folders.py @@ -0,0 +1,284 @@ +"""Config folder API endpoints.""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.config_folder import ConfigFolder +from src.schemas.config_folder import ( + ConfigFolderCreate, + ConfigFolderUpdate, + ConfigFolderResponse, + ProjectOverrideCreate, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/config-folders", tags=["config-folders"]) + + +@router.get("", summary="List config folders", description="Get all config folders for the current user.") +async def list_config_folders( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List config folders for the current user.""" + query = select(ConfigFolder).where(ConfigFolder.user_id == user_id) + result = await session.execute(query) + folders = result.scalars().all() + + return { + "folders": [ + { + "id": str(f.id), + "user_id": str(f.user_id), + "name": f.name, + "description": f.description, + "mount_path": f.mount_path, + "files": f.files, + "project_overrides": f.project_overrides, + "is_active": f.is_active, + "created_at": f.created_at.isoformat() if f.created_at else None, + "updated_at": f.updated_at.isoformat() if f.updated_at else None, + } + for f in folders + ] + } + + +@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED) +async def create_config_folder( + data: ConfigFolderCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a config folder.""" + # Check for duplicate name + existing = await session.scalar( + select(ConfigFolder).where( + ConfigFolder.user_id == user_id, + ConfigFolder.name == data.name, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config folder with name '{data.name}' already exists" + ) + + folder = ConfigFolder( + user_id=user_id, + name=data.name, + description=data.description, + mount_path=data.mount_path, + files=data.files, + ) + session.add(folder) + await session.commit() + await session.refresh(folder) + + return { + "id": str(folder.id), + "user_id": str(folder.user_id), + "name": folder.name, + "description": folder.description, + "mount_path": folder.mount_path, + "files": folder.files, + "project_overrides": folder.project_overrides, + "is_active": folder.is_active, + "created_at": folder.created_at.isoformat() if folder.created_at else None, + "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, + } + + +@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.") +async def update_config_folder( + folder_id: uuid.UUID, + data: ConfigFolderUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a config folder.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + if data.name is not None: + folder.name = data.name + if data.description is not None: + folder.description = data.description + if data.mount_path is not None: + folder.mount_path = data.mount_path + if data.files is not None: + folder.files = data.files + if data.is_active is not None: + folder.is_active = data.is_active + + await session.commit() + await session.refresh(folder) + + return { + "id": str(folder.id), + "user_id": str(folder.user_id), + "name": folder.name, + "description": folder.description, + "mount_path": folder.mount_path, + "files": folder.files, + "project_overrides": folder.project_overrides, + "is_active": folder.is_active, + "created_at": folder.created_at.isoformat() if folder.created_at else None, + "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, + } + + +@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT) +async def delete_config_folder( + folder_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Delete a config folder.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + await session.delete(folder) + await session.commit() + + +class ProjectOverrideWithId(ProjectOverrideCreate): + project_id: uuid.UUID = Field(description="Project ID for the override") + + +@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.") +async def get_config_folder( + folder_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get a config folder by ID.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + return { + "id": str(folder.id), + "user_id": str(folder.user_id), + "name": folder.name, + "description": folder.description, + "mount_path": folder.mount_path, + "files": folder.files, + "project_overrides": folder.project_overrides, + "is_active": folder.is_active, + "created_at": folder.created_at.isoformat() if folder.created_at else None, + "updated_at": folder.updated_at.isoformat() if folder.updated_at else None, + } + + +@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.") +async def add_project_override( + folder_id: uuid.UUID, + data: ProjectOverrideWithId, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Add a project override to a config folder.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + # Initialize project_overrides if None + if folder.project_overrides is None: + folder.project_overrides = {} + + # Add/update override + override_data = {} + if data.mount_path is not None: + override_data["mount_path"] = data.mount_path + if data.files is not None: + override_data["files"] = data.files + + # Use a copy to trigger SQLAlchemy change detection on JSONB + current_overrides = dict(folder.project_overrides or {}) + current_overrides[str(data.project_id)] = override_data + folder.project_overrides = current_overrides + + await session.commit() + await session.refresh(folder) + + return { + "id": str(folder.id), + "project_overrides": folder.project_overrides, + } + + +@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.") +async def update_project_override( + folder_id: uuid.UUID, + project_id: uuid.UUID, + data: ProjectOverrideCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a project override.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + # Initialize project_overrides if None + if folder.project_overrides is None: + folder.project_overrides = {} + + # Update override + current_overrides = dict(folder.project_overrides or {}) + override_data = current_overrides.get(str(project_id), {}) + if data.mount_path is not None: + override_data["mount_path"] = data.mount_path + if data.files is not None: + override_data["files"] = data.files + + current_overrides[str(project_id)] = override_data + folder.project_overrides = current_overrides + + # Mark the field as modified to ensure SQLAlchemy detects the change + from sqlalchemy.orm.attributes import flag_modified + flag_modified(folder, "project_overrides") + + await session.commit() + await session.refresh(folder) + + return { + "id": str(folder.id), + "project_overrides": folder.project_overrides, + } + + +@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.") +async def remove_project_override( + folder_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Remove a project override.""" + folder = await session.get(ConfigFolder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found") + + # Remove override if exists + current_overrides = dict(folder.project_overrides or {}) + if str(project_id) in current_overrides: + del current_overrides[str(project_id)] + folder.project_overrides = current_overrides + await session.commit() + await session.refresh(folder) + + return { + "id": str(folder.id), + "project_overrides": folder.project_overrides or {}, + } diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 36dbbda..593cef4 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -1,1008 +1,299 @@ """Config profile API endpoints.""" import logging -import os -import subprocess import uuid -from typing import Any -from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field, field_validator, model_validator +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.api.shared_validators import validate_env_vars as _validate_env_vars from src.auth.dependencies import get_current_user_id, get_db_session -from src.models.config_profile import ConfigProfile, ConfigProfileInclude -from src.models.project import Project +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile from src.models.tool_type import ToolType -from src.services.config_profile_resolver import ( - ConfigProfileCycleError, - check_include_cycle, - resolve_profile, - resolved_profile_to_dict, +from src.models.user_config import UserConfig +from src.schemas.config_profile import ( + ConfigIncludeCreate, + ConfigIncludeUpdate, + ConfigMountCreate, + ConfigMountUpdate, + ConfigProfileCreate, + ConfigProfileUpdate, + DefaultProfilesUpdate, +) +from src.services.config_profiles import ( + check_duplicate_include, + check_duplicate_mount_path, + check_duplicate_name, + get_default_profile_for_tool_type, + get_default_profiles, + get_owned_profile, + include_to_dict, + list_includes_for_profile, + list_mounts_for_profile, + mount_to_dict, + profile_to_dict, + set_default_profiles, + validate_includes_no_cycle, ) -from src.utils.git_url_parser import parse_git_url 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 GitMountMapping(BaseModel): - source_path: str = Field( - description="Path within repository (supports glob patterns)" - ) - target_path: str = Field(description="Absolute path inside container") - - @field_validator("source_path") - @classmethod - def validate_source_path(cls, v: str) -> str: - if v.startswith("/"): - raise ValueError("source_path must be relative (no leading /)") - if ".." in v: - raise ValueError("source_path cannot contain path traversal (..)") - return v - - @field_validator("target_path") - @classmethod - def validate_target_path(cls, v: str) -> str: - if ".." in v: - raise ValueError("target_path cannot contain path traversal (..)") - return v - - -class GitMountItem(BaseModel): - remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") - source_path: str | None = Field( - default=None, description="Path within repository (legacy single mapping)" - ) - target_path: str | None = Field( - default=None, - description="Absolute path inside container (legacy single mapping)", - ) - branch: str | None = Field(default=None, description="Optional branch or tag name") - mappings: list[GitMountMapping] | None = Field( - default=None, description="Multiple source/target mappings from the same repo" - ) - - @field_validator("remote_url") - @classmethod - def validate_remote_url(cls, v: str) -> str: - if not v.startswith(("http://", "https://", "git@", "ssh://")): - raise ValueError( - "remote_url must be a valid git URL (https://, git@, or ssh://)" - ) - return v - - @field_validator("source_path") - @classmethod - def validate_source_path(cls, v: str | None) -> str | None: - if v is None: - return v - if v.startswith("/"): - raise ValueError("source_path must be relative (no leading /)") - if ".." in v: - raise ValueError("source_path cannot contain path traversal (..)") - return v - - @field_validator("target_path") - @classmethod - def validate_target_path(cls, v: str | None) -> str | None: - if v is None: - return v - if ".." in v: - raise ValueError("target_path cannot contain path traversal (..)") - return v - - @model_validator(mode="after") - def check_mappings_or_legacy(self): - has_legacy = self.source_path is not None and self.target_path is not None - has_mappings = self.mappings is not None and len(self.mappings) > 0 - if not has_legacy and not has_mappings: - raise ValueError( - "Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'" - ) - return self - - -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 not path: - raise ValueError(f"Invalid file path: {path}") - if path.startswith("/"): - raise ValueError( - f"Mount file paths must be relative (got: {path}). " - f"The mount target defines the absolute container path." - ) - return v - - -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}" - ) - git_mounts: list[GitMountItem] = Field( - default_factory=list, description="Git repository mounts" - ) - is_default: bool = Field( - default=False, description="Whether this is the default profile for its scope" - ) - - @field_validator("project_id", "tool_type_id") - @classmethod - def validate_uuids(cls, v: str | None) -> str | None: - return _validate_uuid(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict) -> dict: - for path in v.keys(): - if ".." in path or not path: - raise ValueError(f"Invalid file path: {path}") - if path.startswith("/"): - raise ValueError( - f"File paths must be relative (got: {path}). " - f"Use Mounts for absolute container paths." - ) - return v - - @field_validator("env_vars") - @classmethod - def validate_env_vars(cls, v: dict) -> dict: - result = _validate_env_vars(v) - if result is None: - raise ValueError("env_vars must be a JSON object") - return result - - @field_validator("runtime_hints") - @classmethod - def validate_runtime_hints(cls, v: dict) -> dict: - if not isinstance(v, dict): - raise ValueError("runtime_hints must be a JSON object") - return v - - @field_validator("mounts") - @classmethod - def validate_mounts(cls, v: list) -> list: - if not isinstance(v, list): - raise ValueError("mounts must be a JSON array") - return v - - -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}" - ) - git_mounts: list[GitMountItem] | None = Field( - default=None, description="Git repository mounts" - ) - is_default: bool | None = Field( - default=None, description="Whether this is the default profile" - ) - - @field_validator("project_id", "tool_type_id") - @classmethod - def validate_uuids(cls, v: str | None) -> str | None: - return _validate_uuid(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict | None) -> dict | None: - if v is None: - return v - for path in v.keys(): - if ".." in path or path.startswith("/") or not path: - raise ValueError(f"Invalid file path: {path}") - return v - - -class ConfigProfileIncludeUpdate(BaseModel): - includes: list[str] = Field(description="Ordered list of included profile IDs") - - @field_validator("includes") - @classmethod - def validate_includes(cls, v: list) -> list: - for item in v: - try: - uuid.UUID(item) - except ValueError: - raise ValueError(f"Invalid UUID in includes: {item}") - return v - - -class ConfigProfileResponse(BaseModel): - id: str - user_id: str - name: str - description: str | None - project_id: str | None - tool_type_id: str | None - env_vars: dict - runtime_hints: dict - mounts: list - files: dict - git_mounts: list - is_default: bool - includes: list[dict] - created_at: str - updated_at: str - - -async def _get_profile_with_includes( - session: AsyncSession, profile_id: uuid.UUID -) -> ConfigProfile | None: - """Fetch a profile with includes eagerly loaded.""" - result = await session.execute( - select(ConfigProfile) - .where(ConfigProfile.id == profile_id) - .options(selectinload(ConfigProfile.includes)) - ) - 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" - ) - - -async def _validate_git_mounts( - session: AsyncSession, - user_id: uuid.UUID, - git_mounts: list[Any], - project_id: uuid.UUID | None = None, -) -> None: - """Validate git mount URLs. - - Simply checks that remote_url looks like a valid git URL. - Actual clone validation happens at instance startup time. - """ - for mount in git_mounts: - remote_url = mount.get("remote_url") - if not remote_url: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Git mount missing remote_url", - ) - - if not remote_url.startswith(("http://", "https://", "git@", "ssh://")): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid git URL: {remote_url}", - ) - - -def _profile_to_response( - profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None +@router.get("") +async def list_config_profiles( + tool_type_id: str | None = None, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), ) -> dict: + query = select(ConfigProfile).where(ConfigProfile.user_id == user_id) + if tool_type_id: + tool_type = await session.get(ToolType, uuid.UUID(tool_type_id)) + if tool_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + result = await session.execute(query.order_by(ConfigProfile.name)) + return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]} + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_config_profile( + data: ConfigProfileCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + await check_duplicate_name(session, user_id, data.name) + profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description) + session.add(profile) + await session.commit() + await session.refresh(profile) + return profile_to_dict(profile) + + +@router.get("/defaults") +async def get_default_profiles_endpoint( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + return await get_default_profiles(session, user_id) + + +@router.put("/defaults") +async def set_default_profiles_endpoint( + data: DefaultProfilesUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + return await set_default_profiles(session, user_id, data.default_profiles) + + +@router.get("/defaults/{tool_type_id}") +async def get_default_profile_for_tool_type_endpoint( + tool_type_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + return await get_default_profile_for_tool_type(session, user_id, tool_type_id) + + +@router.get("/{profile_id}") +async def get_config_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + profile = await session.get( + ConfigProfile, + profile_id, + options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)], + ) + if profile is None or profile.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found") + includes_data = [] + for inc in profile.includes: + included_profile = await session.get(ConfigProfile, inc.included_profile_id) + includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None)) return { - "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 [], - "git_mounts": profile.git_mounts or [], - "files": profile.files or {}, - "is_default": profile.is_default, - "includes": [ - { - "id": str(inc.id), - "included_profile_id": str(inc.included_profile_id), - "order_index": inc.order_index, - } - for inc in (includes or profile.includes) - ], - "created_at": profile.created_at.isoformat() if profile.created_at else None, - "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + **profile_to_dict(profile), + "includes": includes_data, + "mounts": [mount_to_dict(m) for m in profile.mounts], } -@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) - - # Validate git mounts reference existing repositories - if data.git_mounts: - git_mounts_data = [ - m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts - ] - await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid) - - # Check size - size = _calculate_profile_size(data.model_dump()) - if size > MAX_PROFILE_SIZE_BYTES: - raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit", - ) - - profile = ConfigProfile( - user_id=user_uuid, - name=data.name, - description=data.description, - project_id=project_uuid, - tool_type_id=tool_uuid, - env_vars=data.env_vars, - runtime_hints=data.runtime_hints, - mounts=[m.model_dump() for m in data.mounts], - git_mounts=[m.model_dump() for m in data.git_mounts], - files=data.files, - is_default=data.is_default, - ) - session.add(profile) - await session.commit() - - # Re-fetch with includes to avoid lazy loading issues - result = await session.execute( - select(ConfigProfile) - .where(ConfigProfile.id == profile.id) - .options(selectinload(ConfigProfile.includes)) - ) - profile = result.scalar_one() - - logger.debug("Created config profile %s for user %s", profile.id, user_uuid) - return _profile_to_response(profile) - - -@router.get("/{profile_id}", response_model=ConfigProfileResponse) -async def get_config_profile( - profile_id: str, - current_user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -): - """Get a config profile by ID.""" - profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) - if profile is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" - ) - if profile.user_id != current_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" - ) - return _profile_to_response(profile) - - -@router.put("/{profile_id}", response_model=ConfigProfileResponse) +@router.put("/{profile_id}") async def update_config_profile( - profile_id: str, + profile_id: uuid.UUID, data: ConfigProfileUpdate, - current_user_id: uuid.UUID = Depends(get_current_user_id), + user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -): - """Update a config profile.""" - profile = await _get_profile_with_includes(session, uuid.UUID(profile_id)) - if profile is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found" - ) - if profile.user_id != current_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized" - ) - - update_data = data.model_dump(exclude_unset=True) - - # Handle name uniqueness - if "name" in update_data: - existing = await session.execute( - select(ConfigProfile).where( - ConfigProfile.user_id == profile.user_id, - ConfigProfile.name == update_data["name"], - ConfigProfile.id != profile.id, - ) - ) - if existing.scalar_one_or_none() is not None: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Profile with name '{update_data['name']}' already exists", - ) - - # Validate references - project_uuid = ( - uuid.UUID(update_data["project_id"]) - if "project_id" in update_data and update_data["project_id"] - else (profile.project_id if "project_id" not in update_data else None) - ) - tool_uuid = ( - uuid.UUID(update_data["tool_type_id"]) - if "tool_type_id" in update_data and update_data["tool_type_id"] - else (profile.tool_type_id if "tool_type_id" not in update_data else None) - ) - await _check_access(session, profile.user_id, project_uuid, tool_uuid) - - # Validate git mounts reference existing repositories - if "git_mounts" in update_data and update_data["git_mounts"] is not None: - git_mounts_data = [ - m.model_dump() if hasattr(m, "model_dump") else m - for m in update_data["git_mounts"] - ] - await _validate_git_mounts( - session, profile.user_id, git_mounts_data, project_uuid - ) - - # Check size - current_data = _profile_to_response(profile) - merged = {**current_data, **update_data} - size = _calculate_profile_size(merged) - if size > MAX_PROFILE_SIZE_BYTES: - raise HTTPException( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"Profile size exceeds {MAX_PROFILE_SIZE_MB}MB limit", - ) - - # Apply updates - for field_name, value in update_data.items(): - if field_name in ("project_id", "tool_type_id"): - value = uuid.UUID(value) if value else None - elif field_name == "mounts" and value is not None: - value = [m.model_dump() if not isinstance(m, dict) else m for m in value] - elif field_name == "git_mounts" and value is not None: - value = [m.model_dump() if not isinstance(m, dict) else m for m in value] - setattr(profile, field_name, value) - +) -> dict: + profile = await get_owned_profile(profile_id, user_id, session) + if data.name is not None: + await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id) + profile.name = data.name + if data.description is not None: + profile.description = data.description await session.commit() - - # Re-fetch with includes to avoid lazy loading issues - result = await session.execute( - select(ConfigProfile) - .where(ConfigProfile.id == profile.id) - .options(selectinload(ConfigProfile.includes)) - ) - profile = result.scalar_one() - - logger.debug("Updated config profile %s", profile.id) - return _profile_to_response(profile) + await session.refresh(profile) + return profile_to_dict(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), + profile_id: uuid.UUID, + 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" - ) - +) -> None: + profile = await get_owned_profile(profile_id, user_id, session) await session.delete(profile) await session.commit() - logger.debug("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), +@router.get("/{profile_id}/includes") +async def list_profile_includes( + profile_id: uuid.UUID, + 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" - ) +) -> dict: + await get_owned_profile(profile_id, user_id, session) + return await list_includes_for_profile(session, profile_id) - # 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 - ) +@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED) +async def add_profile_include( + profile_id: uuid.UUID, + data: ConfigIncludeCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + profile = await get_owned_profile(profile_id, user_id, session) + included_profile_id = uuid.UUID(data.included_profile_id) + if included_profile_id == profile_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself") + included_profile = await session.get(ConfigProfile, included_profile_id) + if included_profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found") + if included_profile.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user") + await check_duplicate_include(session, profile_id, included_profile_id) + await validate_includes_no_cycle(session, profile_id, included_profile_id) + include = ConfigInclude( + profile_id=profile_id, + included_profile_id=included_profile_id, + order_index=data.order_index, ) - for existing in result.scalars().all(): - await session.delete(existing) - await session.flush() + session.add(include) + await session.commit() + await session.refresh(include) + return include_to_dict(include, included_profile.name) - # Add new includes - for order_index, inc_uuid in enumerate(included_uuids): - include = ConfigProfileInclude( - profile_id=profile.id, - included_profile_id=inc_uuid, - order_index=order_index, - ) - session.add(include) - await session.flush() +@router.put("/{profile_id}/includes/{include_id}") +async def update_profile_include( + profile_id: uuid.UUID, + include_id: uuid.UUID, + data: ConfigIncludeUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + await get_owned_profile(profile_id, user_id, session) + include = await session.get(ConfigInclude, include_id) + if include is None or include.profile_id != profile_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") + include.order_index = data.order_index + await session.commit() + await session.refresh(include) + included_profile = await session.get(ConfigProfile, include.included_profile_id) + return include_to_dict(include, included_profile.name if included_profile else None) + + +@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_profile_include( + profile_id: uuid.UUID, + include_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + await get_owned_profile(profile_id, user_id, session) + include = await session.get(ConfigInclude, include_id) + if include is None or include.profile_id != profile_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") + await session.delete(include) await session.commit() - # 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.debug("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), +@router.get("/{profile_id}/mounts") +async def list_profile_mounts( + profile_id: uuid.UUID, + 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) +) -> dict: + await get_owned_profile(profile_id, user_id, session) + return await list_mounts_for_profile(session, profile_id) -@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), +@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED) +async def add_profile_mount( + profile_id: uuid.UUID, + data: ConfigMountCreate, + user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -): - """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) +) -> dict: + profile = await get_owned_profile(profile_id, user_id, session) + await check_duplicate_mount_path(session, profile_id, data.target_path) + mount = ConfigMount( + profile_id=profile_id, + target_path=data.target_path, + mode=data.mode, + files=data.files, + order_index=data.order_index, ) - 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} + session.add(mount) + await session.commit() + await session.refresh(mount) + return mount_to_dict(mount) -class ValidateGitUrlRequest(BaseModel): - url: str = Field(description="Git remote URL to validate") - ssh_key_id: str | None = Field( - default=None, description="Optional SSH key ID for private repos" - ) - - -class ValidateGitUrlResponse(BaseModel): - valid: bool - suggested_url: str | None = None - branches: list[str] | None = None - default_branch: str | None = None - error: str | None = None - error_code: str | None = None - - -@router.post("/validate-git-url", response_model=ValidateGitUrlResponse) -async def validate_git_url( - data: ValidateGitUrlRequest, - current_user_id: uuid.UUID = Depends(get_current_user_id), +@router.put("/{profile_id}/mounts/{mount_id}") +async def update_profile_mount( + profile_id: uuid.UUID, + mount_id: uuid.UUID, + data: ConfigMountUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), -) -> ValidateGitUrlResponse: - """Validate a git remote URL and list available branches. +) -> dict: + await get_owned_profile(profile_id, user_id, session) + mount = await session.get(ConfigMount, mount_id) + if mount is None or mount.profile_id != profile_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") + if data.target_path is not None: + await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id) + mount.target_path = data.target_path + if data.files is not None: + mount.files = data.files + if data.order_index is not None: + mount.order_index = data.order_index + await session.commit() + await session.refresh(mount) + return mount_to_dict(mount) - Parses the URL, suggests corrections for browser URLs, and runs - git ls-remote to verify reachability and enumerate branches. - """ - parse_result = parse_git_url(data.url) - original_url = data.url.strip() - url_to_check = parse_result.get("base_url") or original_url - if not url_to_check: - return ValidateGitUrlResponse( - valid=False, - error=parse_result.get("message", "Invalid URL"), - error_code=parse_result.get("error_code", "INVALID_URL"), - ) - - # If the URL needed parsing, return suggestion without checking remote - if parse_result.get("needs_parsing") and url_to_check != original_url: - return ValidateGitUrlResponse( - valid=False, - suggested_url=url_to_check, - error=parse_result.get("message"), - error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"), - ) - - # Optional SSH key for private repos - env = None - key_path = None - if data.ssh_key_id: - from src.models.ssh_key import SSHKey - from src.services.ssh_keys import _get_fernet - - try: - ssh_key_uuid = uuid.UUID(data.ssh_key_id) - except ValueError: - return ValidateGitUrlResponse( - valid=False, - error="Invalid SSH key ID format", - error_code="INVALID_SSH_KEY", - ) - - ssh_key = await session.get(SSHKey, ssh_key_uuid) - if ssh_key is None or ssh_key.user_id != current_user_id: - return ValidateGitUrlResponse( - valid=False, - error="SSH key not found or not authorized", - error_code="SSH_KEY_NOT_FOUND", - ) - - import tempfile - - fernet = _get_fernet() - private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() - fd, key_path = tempfile.mkstemp(prefix="ssh_key_") - try: - os.write(fd, private_key.encode()) - finally: - os.close(fd) - os.chmod(key_path, 0o600) - env = { - "GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - } - - try: - result = subprocess.run( - ["git", "ls-remote", "--heads", url_to_check], - capture_output=True, - text=True, - timeout=30, - env={**os.environ, **env} if env else None, - ) - except subprocess.TimeoutExpired: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - return ValidateGitUrlResponse( - valid=False, - error="Remote repository check timed out", - error_code="TIMEOUT", - ) - except FileNotFoundError: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - return ValidateGitUrlResponse( - valid=False, - error="git command not found on server", - error_code="GIT_NOT_FOUND", - ) - finally: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - - if result.returncode != 0: - stderr = result.stderr.strip() - if ( - "could not resolve" in stderr.lower() - or "unable to access" in stderr.lower() - ): - error_msg = "Could not reach repository. Check the URL and network access." - error_code = "UNREACHABLE" - elif ( - "authentication" in stderr.lower() or "permission denied" in stderr.lower() - ): - error_msg = ( - "Authentication failed. Provide an SSH key for private repositories." - ) - error_code = "AUTH_FAILED" - else: - error_msg = f"Repository not accessible: {stderr[:200]}" - error_code = "REMOTE_ERROR" - return ValidateGitUrlResponse( - valid=False, - error=error_msg, - error_code=error_code, - ) - - # Parse branches from ls-remote output - branches: list[str] = [] - default_branch = "main" - for line in result.stdout.strip().split("\n"): - if not line.strip(): - continue - parts = line.split() - if len(parts) == 2: - ref = parts[1] - # refs/heads/branch-name - if ref.startswith("refs/heads/"): - branch_name = ref[len("refs/heads/") :] - branches.append(branch_name) - if branch_name in ("main", "master"): - default_branch = branch_name - - if not branches: - return ValidateGitUrlResponse( - valid=False, - error="No branches found in remote repository", - error_code="NO_BRANCHES", - ) - - return ValidateGitUrlResponse( - valid=True, - suggested_url=url_to_check if url_to_check != original_url else None, - branches=branches, - default_branch=default_branch, - ) +@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_profile_mount( + profile_id: uuid.UUID, + mount_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + await get_owned_profile(profile_id, user_id, session) + mount = await session.get(ConfigMount, mount_id) + if mount is None or mount.profile_id != profile_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") + await session.delete(mount) + await session.commit() diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 0b3892d..9d71e73 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -1,677 +1,84 @@ +"""Git repository API endpoints.""" + import logging -import os -import shutil -import subprocess import uuid -from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel, ConfigDict -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import ( - _get_owned_project, - _get_user, - get_current_user_id, - get_db_session, +from src.auth.dependencies import get_current_user, get_db_session, get_owned_project +from src.models.project import Project +from src.models.user import User +from src.schemas.git_repository import ( + BranchCreateRequest, + CheckoutRequest, + CommitRequest, + CommitResponse, + FetchResponse, + FileContentResponse, + FileListResponse, + FileUpdateRequest, + FileUpdateResponse, + GitRepositoryCreate, + GitRepositoryResponse, + MergeRequest, + MergeResponse, + PullResponse, + PushResponse, + StatusResponse, + URLParseRequest, + URLParseResponse, ) -from src.config import Settings -from src.models.git_repository import GitRepository -from src.models.ssh_key import SSHKey -from src.utils.git_files import ( - commit_file, - get_file_content, - list_branches, - list_tree, -) -from src.utils.git_control import ( - checkout_branch, - commit_changes, - create_branch, - delete_branch, - fetch, - get_status, - merge, - pull, - push, -) -from src.utils.git_history import get_commit_detail, get_commit_history +from src.services.git import control as git_control +from src.services.git import files as git_files +from src.services.git.repository import create_repository, delete_repository, list_repositories from src.utils.git_url_parser import parse_git_url -from src.services.ssh_keys import _get_fernet router = APIRouter(prefix="/projects", tags=["git-repositories"]) - logger = logging.getLogger(__name__) -def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: - """Generate the filesystem path for a repository. - - Args: - user_id: UUID of the repository owner. - project_id: UUID of the project. - name: Repository name. - - Returns: - Absolute path to the repository directory. - """ - base = Settings().repo_base_path or "/data/repos" - return os.path.join(base, str(user_id), str(project_id), f"{name}.git") - - -def _build_provider_clone_url(owner: str, repo: str) -> str: - """Build the SSH clone URL for the fixed git provider.""" - return f"git@git.commumedia.org:{owner}/{repo}.git" - - -def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None: - """Prepare environment variables for git commands with SSH authentication. - - Returns a dict of extra env vars, or None if no SSH key provided. - The caller is responsible for cleaning up the temporary key file. - """ - if ssh_key is None: - return None - - import tempfile - - # Decrypt private key - fernet = _get_fernet() - private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() - - # Write to temp file with restricted permissions - fd, key_path = tempfile.mkstemp(prefix="ssh_key_") - try: - os.write(fd, private_key.encode()) - finally: - os.close(fd) - os.chmod(key_path, 0o600) - - # Return env vars and the key path for cleanup - env = { - "GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - } - return env, key_path - - -def _preflight_remote_repository( - remote_url: str, ssh_key: SSHKey | None = None -) -> None: - """Verify a remote repository is reachable before cloning.""" - env = None - key_path = None - - if ssh_key is not None: - ssh_result = _prepare_ssh_env(ssh_key) - if ssh_result: - env, key_path = ssh_result - - try: - result = subprocess.run( - ["git", "ls-remote", remote_url], - capture_output=True, - text=True, - timeout=60, - env={**os.environ, **env} if env else None, - ) - except subprocess.TimeoutExpired: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="remote repository check timed out", - ) - except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="git command not found", - ) - finally: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - - if result.returncode != 0: - logger.error( - "Preflight check failed for %s: stderr=%s", remote_url, result.stderr - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"repository not found or inaccessible: {result.stderr}", - ) - - -def _clone_working_repository( - remote_url: str, repo_path: str, ssh_key: SSHKey | None = None -) -> None: - env = None - key_path = None - - if ssh_key is not None: - ssh_result = _prepare_ssh_env(ssh_key) - if ssh_result: - env, key_path = ssh_result - - try: - result = subprocess.run( - ["git", "clone", remote_url, repo_path], - capture_output=True, - text=True, - timeout=300, - env={**os.environ, **env} if env else None, - ) - except subprocess.TimeoutExpired: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out" - ) - except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="git command not found", - ) - finally: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - - if result.returncode != 0: - logger.error("Clone failed for %s: stderr=%s", remote_url, result.stderr) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to clone repository: {result.stderr}", - ) - - -def _init_working_repository(repo_path: str) -> None: - try: - result = subprocess.run( - ["git", "init", "-b", "main", repo_path], - capture_output=True, - text=True, - ) - except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="git command not found", - ) - - if result.returncode == 0: - return - - fallback = subprocess.run( - ["git", "init", repo_path], - capture_output=True, - text=True, - ) - if fallback.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to initialize repository: {fallback.stderr}", - ) - - ref_result = subprocess.run( - ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"], - capture_output=True, - text=True, - ) - if ref_result.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to set initial branch: {ref_result.stderr}", - ) - - -class GitRepositoryCreate(BaseModel): - name: str - remote_url: str | None = None - force_original_url: bool = False - ssh_key_id: str | None = None - - -class URLParseRequest(BaseModel): - url: str - - -class URLParseResponse(BaseModel): - original_url: str - base_url: str | None - is_valid_clone_url: bool - needs_parsing: bool - host: str | None - message: str - error_code: str | None - - -class GitRepositoryResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - path: str - project_id: uuid.UUID | None - owner_id: uuid.UUID - is_mirror: bool - remote_url: str | None - last_push: datetime | None - ssh_key_id: uuid.UUID | None - created_at: datetime - updated_at: datetime - - -@router.get( - "/repositories", - response_model=list[GitRepositoryResponse], - summary="List all user repositories", - description="List all git repositories owned by the user, including external repositories not tied to any project.", -) -async def list_user_repositories( - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> list[GitRepository]: - """List all repositories owned by the user. - - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of all repositories owned by the user. - """ - result = await session.execute( - select(GitRepository).where(GitRepository.owner_id == user_id) - ) - return list(result.scalars().all()) - - -@router.post( - "/repositories/parse-url", - response_model=URLParseResponse, - summary="Parse a git URL", - description="Parse a git URL and detect if it's a browser URL that needs correction.", -) -async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: - """Parse a git URL and detect if it's a browser URL that needs correction. - - Args: - data: Request containing the URL to parse. - - Returns: - Parsed URL information including whether it needs parsing and suggested corrections. - """ - result = parse_git_url(data.url) - return URLParseResponse(**result) - - -@router.post( - "/repositories", - response_model=GitRepositoryResponse, - status_code=status.HTTP_201_CREATED, - summary="Create an external repository", - description="Create a new external git repository (not tied to any project). Can clone from remote URL.", -) -async def create_external_repository( - data: GitRepositoryCreate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> GitRepository: - """Create a new external git repository. - - External repositories are not tied to any project and can be used - across all projects for config profile git mounts. - - Args: - data: Repository creation data including name and optional remote URL. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The newly created external repository. - """ - _user = await _get_user(session, user_id) - - # Check for duplicate name (external repos only) - existing = await session.execute( - select(GitRepository).where( - GitRepository.project_id.is_(None), - GitRepository.owner_id == user_id, - GitRepository.name == data.name, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository name already exists", - ) - - # Validate and potentially correct the URL - remote_url = data.remote_url - if remote_url and not data.force_original_url: - parse_result = parse_git_url(remote_url) - if parse_result["needs_parsing"] and parse_result["base_url"]: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": "The provided URL appears to be a browser URL, not a git clone URL", - "suggested_url": parse_result["base_url"], - "original_url": remote_url, - "error_code": "URL_NEEDS_PARSING", - }, - ) - if parse_result["base_url"]: - remote_url = parse_result["base_url"] - - # Validate SSH key if provided - ssh_key_id = None - ssh_key = None - if data.ssh_key_id: - try: - ssh_key_id = uuid.UUID(data.ssh_key_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - - ssh_key = await session.get(SSHKey, ssh_key_id) - if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) - if ssh_key.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user", - ) - - if remote_url: - _preflight_remote_repository(remote_url, ssh_key) - - # Create external repo with no project - repo = GitRepository( - name=data.name, - path="", # Will be set after clone - project_id=None, - owner_id=user_id, - remote_url=remote_url, - ssh_key_id=ssh_key_id, - ) - session.add(repo) - await session.flush() - - # Set path and optionally clone - repo_path = f"/data/repos/external/{user_id}/{repo.id}" - repo.path = repo_path - - if remote_url: - try: - _clone_working_repository(remote_url, repo_path, ssh_key) - repo.is_mirror = False - except Exception as exc: - await session.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to clone repository: {exc}", - ) - else: - # Initialize empty repo - os.makedirs(repo_path, exist_ok=True) - subprocess.run(["git", "init", repo_path], check=True, capture_output=True) - repo.is_mirror = False - - await session.commit() - return repo - - -@router.get( - "/{project_id}/repositories", - response_model=list[GitRepositoryResponse], - summary="List repositories", - description="List all git repositories in a project.", -) -async def list_repositories( +@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse]) +async def list_repositories_endpoint( project_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> list[GitRepository]: - """List all repositories in a project. - - Args: - project_id: UUID of the project. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of repositories in the project. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - result = await session.execute( - select(GitRepository).where(GitRepository.project_id == project_id) - ) - return list(result.scalars().all()) +): + return await list_repositories(session, project_id) -@router.delete( - "/{project_id}/repositories/{repo_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a repository", - description="Delete a git repository from the project and remove it from disk.", -) -async def delete_repository( +@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) +async def create_repository_endpoint( + project_id: uuid.UUID, + data: GitRepositoryCreate, + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), + session: AsyncSession = Depends(get_db_session), +): + return await create_repository(session, project_id, data, user) + + +@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_repository_endpoint( project_id: uuid.UUID, repo_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> Response: - """Delete a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Empty response with 204 status code. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - # Remove from disk - if os.path.exists(repo.path): - shutil.rmtree(repo.path) - - await session.delete(repo) - await session.commit() +): + await delete_repository(session, repo_id, project_id) return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post( - "/{project_id}/repositories", - response_model=GitRepositoryResponse, - status_code=status.HTTP_201_CREATED, - summary="Create a repository", - description="Create a new git repository in a project. Can clone from remote or initialize a working repository.", -) -async def create_repository( - project_id: uuid.UUID, - data: GitRepositoryCreate, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> GitRepository: - """Create a new git repository. - - Args: - project_id: UUID of the project. - data: Repository creation data including name and optional remote URL. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The newly created repository. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - # Check for duplicate name - existing = await session.execute( - select(GitRepository).where( - GitRepository.project_id == project_id, - GitRepository.name == data.name, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository name already exists", - ) - - # Validate and potentially correct the URL - remote_url = data.remote_url - if remote_url and not data.force_original_url: - parse_result = parse_git_url(remote_url) - if parse_result["needs_parsing"] and parse_result["base_url"]: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": "The provided URL appears to be a browser URL, not a git clone URL", - "suggested_url": parse_result["base_url"], - "original_url": remote_url, - "error_code": "URL_NEEDS_PARSING", - }, - ) - # Use base_url if it was extracted (for URLs without .git suffix) - if parse_result["base_url"]: - remote_url = parse_result["base_url"] - - # Validate SSH key if provided - ssh_key_id = None - ssh_key = None - if data.ssh_key_id: - try: - ssh_key_id = uuid.UUID(data.ssh_key_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - - ssh_key = await session.get(SSHKey, ssh_key_id) - if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) - if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user or project", - ) - - if remote_url: - _preflight_remote_repository(remote_url, ssh_key) - - repo_path = _get_repo_path(user_id, project_id, data.name) - - # Ensure parent directory exists - os.makedirs(os.path.dirname(repo_path), exist_ok=True) - - if remote_url: - _clone_working_repository(remote_url, repo_path, ssh_key) - else: - _init_working_repository(repo_path) - - repo = GitRepository( - name=data.name, - path=repo_path, - project_id=project_id, - owner_id=user_id, - is_mirror=False, - remote_url=remote_url, - ssh_key_id=ssh_key_id, - ) - session.add(repo) - await session.commit() - await session.refresh(repo) - return repo +@router.post("/repositories/parse-url", response_model=URLParseResponse) +async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: + return URLParseResponse(**parse_git_url(data.url)) -class UpdateSSHKeyRequest(BaseModel): - ssh_key_id: str | None = None +# History - -@router.patch( - "/{project_id}/repositories/{repo_id}/ssh-key", - response_model=GitRepositoryResponse, - summary="Update repository SSH key", - description="Update the SSH key associated with a repository.", -) -async def update_repository_ssh_key( - project_id: uuid.UUID, - repo_id: uuid.UUID, - data: UpdateSSHKeyRequest, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> GitRepository: - """Update the SSH key for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Update data containing the new SSH key ID. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - The updated repository. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - # Validate SSH key if provided - if data.ssh_key_id: - try: - ssh_key_id = uuid.UUID(data.ssh_key_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="invalid ssh_key_id format", - ) - - ssh_key = await session.get(SSHKey, ssh_key_id) - if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" - ) - if ssh_key.user_id != user_id and ssh_key.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="ssh key does not belong to user or project", - ) - - repo.ssh_key_id = ssh_key_id - else: - repo.ssh_key_id = None - - await session.commit() - await session.refresh(repo) - return repo - - -@router.get( - "/{project_id}/repositories/{repo_id}/history", - summary="Get repository history", - description="Get commit history for a repository with optional branch filtering.", -) +@router.get("/{project_id}/repositories/{repo_id}/history") async def get_repository_history( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -679,945 +86,205 @@ async def get_repository_history( branch: str | None = None, limit: int = 100, offset: int = 0, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get commit history for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - view: View type for history display (default: graph). - branch: Optional branch name to filter commits. - limit: Maximum number of commits to return (default: 100). - offset: Number of commits to skip (default: 0). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit history data. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + from src.utils.git_history import get_commit_history + from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) try: - history = get_commit_history( - repo.path, branch=branch, limit=limit, offset=offset - ) - return history + return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) except RuntimeError as e: + logger.warning("Git history failed for %s: %s", repo.path, str(e)) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) - ) + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Git history unavailable: {str(e)}", + ) from e -@router.get( - "/{project_id}/repositories/{repo_id}/commits/{commit_hash}", - summary="Get commit details", - description="Get detailed information about a specific commit.", -) +@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}") async def get_repository_commit( project_id: uuid.UUID, repo_id: uuid.UUID, commit_hash: str, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get detailed information about a specific commit. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - commit_hash: Hash of the commit to retrieve. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit details. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - + from src.utils.git_history import get_commit_detail + from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) try: - detail = get_commit_detail(repo.path, commit_hash) - return detail - except (RuntimeError, ValueError) as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return get_commit_detail(repo.path, commit_hash) + except RuntimeError as e: + logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e)) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Commit detail unavailable: {str(e)}", + ) from e -# File browsing endpoints +# File browsing - -class FileListResponse(BaseModel): - path: str - branch: str - entries: list[dict] - - -class FileContentResponse(BaseModel): - path: str - branch: str - content: str - size: int - encoding: str - language: str | None - is_binary: bool - last_commit: dict | None - - -class BranchesResponse(BaseModel): - branches: list[dict] - default_branch: str - - -class FileUpdateRequest(BaseModel): - path: str - branch: str - content: str - commit_message: str - - -class FileUpdateResponse(BaseModel): - commit_hash: str - message: str - branch: str - - -@router.get( - "/{project_id}/repositories/{repo_id}/files", - response_model=FileListResponse, - summary="List repository files", - description="List files and directories in a repository path.", -) +@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse) async def list_repository_files( project_id: uuid.UUID, repo_id: uuid.UUID, branch: str = "main", path: str = "", - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileListResponse: - """List files and directories in a repository path. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name to browse (default: main). - path: Directory path within the repository (default: root). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of files and directories in the specified path. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - entries = list_tree(repo.path, branch=branch, path=path) - return FileListResponse( - path=path, - branch=branch, - entries=[ - { - "name": e.name, - "type": e.type, - "path": e.path, - "size": e.size, - "mode": e.mode, - "last_commit": e.last_commit, - } - for e in entries - ], - ) - except RuntimeError as e: - logger.error( - "Failed to list files for repo %s (path=%s, branch=%s): %s", - repo_id, - path, - branch, - str(e), - exc_info=True, - ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_files.list_files(session, project_id, repo_id, branch, path) -@router.get( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileContentResponse, - summary="Get file content", - description="Get the content of a file in a repository.", -) +@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse) async def get_repository_file_content( project_id: uuid.UUID, repo_id: uuid.UUID, branch: str, path: str, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileContentResponse: - """Get the content of a file. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name where the file is located. - path: File path within the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - File content and metadata. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - file_content = get_file_content(repo.path, branch=branch, path=path) - return FileContentResponse( - path=file_content.path, - branch=file_content.branch, - content=file_content.content, - size=file_content.size, - encoding=file_content.encoding, - language=file_content.language, - is_binary=file_content.is_binary, - last_commit=file_content.last_commit, - ) - except FileNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="file not found" - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_files.get_file(session, project_id, repo_id, branch, path) -@router.get( - "/{project_id}/repositories/{repo_id}/branches", - response_model=BranchesResponse, - summary="List branches", - description="List all branches in the repository.", -) -async def get_repository_branches( - project_id: uuid.UUID, - repo_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> BranchesResponse: - """List all branches in the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of branches and the default branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - # Try local repo first (.git subdir for normal repos, HEAD for bare) - is_valid_git_repo = os.path.isdir( - os.path.join(repo.path, ".git") - ) or os.path.isfile(os.path.join(repo.path, "HEAD")) - - if is_valid_git_repo: - try: - branches, default_branch = list_branches(repo.path) - return BranchesResponse( - branches=[ - { - "name": b.name, - "is_default": b.is_default, - "last_commit": b.last_commit, - } - for b in branches - ], - default_branch=default_branch, - ) - except RuntimeError as e: - logger.error( - "Failed to list branches for repo %s: %s", - repo_id, - str(e), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) - ) from e - - # Local repo missing/corrupt — try remote if available - if repo.remote_url: - ssh_key = None - if repo.ssh_key_id: - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - - ssh_result = _prepare_ssh_env(ssh_key) - env = None - key_path = None - if ssh_result: - env, key_path = ssh_result - - try: - result = subprocess.run( - ["git", "ls-remote", "--heads", repo.remote_url], - capture_output=True, - text=True, - timeout=30, - env={**os.environ, **env} if env else None, - ) - if result.returncode == 0: - remote_branches = [] - default_branch = "main" - for line in result.stdout.strip().split("\n"): - if line: - parts = line.split("\t") - if len(parts) == 2: - ref = parts[1] - if ref.startswith("refs/heads/"): - branch_name = ref[len("refs/heads/") :] - remote_branches.append(branch_name) - if branch_name in ("main", "master"): - default_branch = branch_name - if remote_branches: - return BranchesResponse( - branches=[ - { - "name": b, - "is_default": b == default_branch, - "last_commit": None, - } - for b in remote_branches - ], - default_branch=default_branch, - ) - else: - logger.warning( - "ls-remote returned %d for repo %s: %s", - result.returncode, - repo_id, - result.stderr, - ) - except subprocess.TimeoutExpired: - logger.warning("ls-remote timed out for repo %s", repo_id) - except Exception as e: - logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e)) - finally: - if key_path and os.path.exists(key_path): - os.unlink(key_path) - - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="repository not found on disk — re-clone or re-create the repository", - ) - - -@router.post( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileUpdateResponse, - summary="Update file content", - description="Update a file and create a commit.", -) +@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse) async def update_repository_file( project_id: uuid.UUID, repo_id: uuid.UUID, data: FileUpdateRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileUpdateResponse: - """Update a file and create a commit. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: File update data including path, branch, content, and commit message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information for the file update. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - # Get user info for commit - user = await _get_user(session, user_id) - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - - try: - commit_hash = commit_file( - repo_path=repo.path, - branch=data.branch, - path=data.path, - content=data.content, - commit_message=data.commit_message, - author_name=author_name, - author_email=author_email, - ) - return FileUpdateResponse( - commit_hash=commit_hash, - message=data.commit_message, - branch=data.branch, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_files.update_file(session, project_id, repo_id, data, user) -# Git Control Endpoints +# Branches - -class StatusResponse(BaseModel): - branch: str - modified: list[str] - added: list[str] - deleted: list[str] - untracked: list[str] - renamed: list[str] - ahead: int - behind: int - - -@router.get( - "/{project_id}/repositories/{repo_id}/status", - response_model=StatusResponse, - summary="Get repository status", - description="Get the working directory status including modified, added, and deleted files.", -) -async def get_repository_status( +@router.get("/{project_id}/repositories/{repo_id}/branches") +async def get_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> StatusResponse: - """Get the working directory status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Repository status including branch, modified files, and ahead/behind counts. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - status_result = get_status(repo.path) - return StatusResponse( - branch=status_result.branch, - modified=status_result.modified, - added=status_result.added, - deleted=status_result.deleted, - untracked=status_result.untracked, - renamed=status_result.renamed, - ahead=status_result.ahead, - behind=status_result.behind, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +) -> dict: + return await git_files.list_branches_with_validation(session, project_id, repo_id) -class BranchCreateRequest(BaseModel): - name: str - base_branch: str = "HEAD" - - -class CheckoutRequest(BaseModel): - branch: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/branches", - summary="Create a branch", - description="Create a new branch in the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/branches") async def create_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, data: BranchCreateRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Branch creation data including name and optional base branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message and branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - create_branch(repo.path, data.name, data.base_branch) - return {"message": f"Branch '{data.name}' created", "branch": data.name} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.create_branch_with_validation(session, project_id, repo_id, data) -@router.delete( - "/{project_id}/repositories/{repo_id}/branches/{branch_name}", - summary="Delete a branch", - description="Delete a branch from the repository.", -) +@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}") async def delete_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, branch_name: str, force: bool = False, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Delete a branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch_name: Name of the branch to delete. - force: Whether to force delete the branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - delete_branch(repo.path, branch_name, force) - return {"message": f"Branch '{branch_name}' deleted"} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force) -@router.post( - "/{project_id}/repositories/{repo_id}/checkout", - summary="Checkout a branch", - description="Checkout a branch in the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/checkout") async def checkout_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, data: CheckoutRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Checkout a branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Checkout request containing the branch name. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message and checked out branch name. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - checkout_branch(repo.path, data.branch) - return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data) -class CommitRequest(BaseModel): - message: str - files: list[str] | None = None +# Git control + +@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse) +async def get_repository_status( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), + session: AsyncSession = Depends(get_db_session), +) -> StatusResponse: + return await git_control.get_status_with_validation(session, project_id, repo_id) -class CommitResponse(BaseModel): - commit_hash: str - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/commit", - response_model=CommitResponse, - summary="Commit changes", - description="Commit changes to the repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse) async def commit_repository_changes( project_id: uuid.UUID, repo_id: uuid.UUID, data: CommitRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> CommitResponse: - """Commit changes to the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Commit request containing message and optional files to commit. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information including hash and message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - # Get user info for commit - user = await _get_user(session, user_id) - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - - try: - commit_hash = commit_changes( - repo_path=repo.path, - message=data.message, - author_name=author_name, - author_email=author_email, - files=data.files, - ) - return CommitResponse( - commit_hash=commit_hash, - message=data.message, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user) + return CommitResponse(commit_hash=result["commit_hash"], message=result["message"]) -class FetchResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/fetch", - response_model=FetchResponse, - summary="Fetch from remote", - description="Fetch updates from the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse) async def fetch_repository( project_id: uuid.UUID, repo_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FetchResponse: - """Fetch from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - fetch(repo.path) - return FetchResponse(message="Fetched from remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.fetch_with_validation(session, project_id, repo_id) -class PullResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/pull", - response_model=PullResponse, - summary="Pull from remote", - description="Pull updates from the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse) async def pull_repository( project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> PullResponse: - """Pull updates from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to pull. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - pull(repo.path, branch) - return PullResponse(message="Pulled from remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.pull_with_validation(session, project_id, repo_id, branch) -class PushResponse(BaseModel): - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/push", - response_model=PushResponse, - summary="Push to remote", - description="Push changes to the remote repository.", -) +@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse) async def push_repository( project_id: uuid.UUID, repo_id: uuid.UUID, branch: str | None = None, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> PushResponse: - """Push changes to remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to push. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - push(repo.path, branch) - return PushResponse(message="Pushed to remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.push_with_validation(session, project_id, repo_id, branch) -class MergeRequest(BaseModel): - source_branch: str - target_branch: str | None = None - message: str | None = None - - -class MergeResponse(BaseModel): - commit_hash: str - message: str - - -@router.post( - "/{project_id}/repositories/{repo_id}/merge", - response_model=MergeResponse, - summary="Merge branches", - description="Merge one branch into another.", -) +@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse) async def merge_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, data: MergeRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> MergeResponse: - """Merge branches. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Merge request containing source branch, optional target branch, and message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Merge result with commit hash and message. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - if not os.path.exists(repo.path): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk" - ) - - try: - commit_hash = merge( - repo_path=repo.path, - source_branch=data.source_branch, - target_branch=data.target_branch, - message=data.message, - ) - return MergeResponse( - commit_hash=commit_hash, - message=data.message or f"Merge {data.source_branch}", - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.merge_with_validation(session, project_id, repo_id, data) diff --git a/apps/api/src/api/health.py b/apps/api/src/api/health.py index e1ec443..51a4ca5 100644 --- a/apps/api/src/api/health.py +++ b/apps/api/src/api/health.py @@ -4,11 +4,18 @@ import time from datetime import datetime, timezone from typing import Any -from fastapi import APIRouter -from pydantic import BaseModel, Field +from fastapi import APIRouter, status from sqlalchemy import text +from src.config import Settings from src.database import SessionLocal +from src.schemas.health import ( + DatabaseHealth, + DatabaseHealthResponse, + DiskHealth, + HealthChecks, + HealthResponse, +) router = APIRouter() @@ -16,45 +23,6 @@ router = APIRouter() _start_time = time.time() -class DatabaseHealth(BaseModel): - """Database health check result.""" - - status: str = Field(description="Database health status", examples=["healthy"]) - response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2]) - - -class DiskHealth(BaseModel): - """Disk space health check result.""" - - status: str = Field(description="Disk health status", examples=["healthy"]) - free_gb: float = Field(description="Free disk space in GB", examples=[45.2]) - total_gb: float = Field(description="Total disk space in GB", examples=[100.0]) - - -class HealthChecks(BaseModel): - """Individual health checks.""" - - database: DatabaseHealth | None = None - disk: DiskHealth | None = None - - -class HealthResponse(BaseModel): - """Overall health check response.""" - - status: str = Field(description="Overall health status", examples=["healthy"]) - timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]) - version: str = Field(description="API version", examples=["0.1.0"]) - checks: HealthChecks = Field(description="Individual health checks") - uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0]) - - -class DatabaseHealthResponse(BaseModel): - """Database-specific health check response.""" - - status: str = Field(description="Database health status", examples=["healthy"]) - response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2]) - - @router.get( "/health", response_model=HealthResponse, diff --git a/apps/api/src/api/instance_proxy.py b/apps/api/src/api/instance_proxy.py index 4145e6c..b629abf 100644 --- a/apps/api/src/api/instance_proxy.py +++ b/apps/api/src/api/instance_proxy.py @@ -2,6 +2,7 @@ import logging import uuid +from typing import Any import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, status diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index d68cacb..0b776a9 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -3,47 +3,24 @@ import shutil import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel, ConfigDict -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import ( - _get_owned_project, - _get_user, - get_current_user_id, - get_db_session, -) +from src.auth.dependencies import get_current_user, get_db_session, get_owned_project from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.tool_instance import ToolInstance +from src.models.user import User +from src.schemas.project import ( + ProjectCreate, + ProjectUpdate, + ProjectResponse, + SetDefaultSSHKeyRequest, +) router = APIRouter(prefix="/projects", tags=["projects"]) -class ProjectCreate(BaseModel): - name: str - description: str | None = None - - -class ProjectUpdate(BaseModel): - name: str | None = None - description: str | None = None - - -class ProjectResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - description: str | None - owner_id: uuid.UUID - default_ssh_key_id: uuid.UUID | None - - -class SetDefaultSSHKeyRequest(BaseModel): - ssh_key_id: uuid.UUID - @router.post( "", @@ -54,7 +31,7 @@ class SetDefaultSSHKeyRequest(BaseModel): ) async def create_project( data: ProjectCreate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> Project: """Create a new project. @@ -67,7 +44,6 @@ async def create_project( Returns: The newly created project. """ - user = await _get_user(session, user_id) project = Project( name=data.name, description=data.description, @@ -82,77 +58,25 @@ async def create_project( @router.get( "", + response_model=list[ProjectResponse], summary="List all projects", - description="Retrieve all projects owned by the authenticated user with repositories and workspaces.", + description="Retrieve all projects owned by the authenticated user.", ) async def list_projects( - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), -) -> list[dict]: +) -> list[Project]: """List all projects for the authenticated user. - Returns projects with nested repositories and workspaces for inline display. + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of projects owned by the user. """ - user = await _get_user(session, user_id) - result = await session.execute( - select(Project) - .where(Project.owner_id == user.id) - .order_by(Project.created_at.desc()) - ) - projects = result.scalars().all() - - from src.models.workspace import Workspace - - enriched = [] - for project in projects: - repos_result = await session.execute( - select(GitRepository).where(GitRepository.project_id == project.id) - ) - repositories = [] - for repo in repos_result.scalars().all(): - ws_result = await session.execute( - select(Workspace).where(Workspace.repo_id == repo.id) - ) - workspaces = [] - for ws in ws_result.scalars().all(): - # Count instances - inst_result = await session.execute( - select(func.count()).where(ToolInstance.workspace_id == ws.id) - ) - instance_count = inst_result.scalar() or 0 - workspaces.append( - { - "id": str(ws.id), - "name": ws.name, - "branch": ws.branch, - "status": ws.status, - "instance_count": instance_count, - } - ) - - repositories.append( - { - "id": str(repo.id), - "name": repo.name, - "remote_url": repo.remote_url, - "workspaces": workspaces, - } - ) - - enriched.append( - { - "id": str(project.id), - "name": project.name, - "description": project.description, - "owner_id": str(project.owner_id), - "repositories": repositories, - "created_at": project.created_at.isoformat() - if project.created_at - else None, - } - ) - - return enriched + result = await session.execute(select(Project).where(Project.owner_id == user.id)) + return list(result.scalars().all()) @router.get( @@ -163,7 +87,8 @@ async def list_projects( ) async def get_project( project_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> Project: """Get a specific project by ID. @@ -176,8 +101,8 @@ async def get_project( Returns: The requested project. """ - await _get_user(session, user_id) - return await _get_owned_project(project_id, user_id, session) + return project + @router.patch( @@ -189,7 +114,8 @@ async def get_project( async def update_project( project_id: uuid.UUID, data: ProjectUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> Project: """Update a project. @@ -203,8 +129,6 @@ async def update_project( Returns: The updated project. """ - await _get_user(session, user_id) - project = await _get_owned_project(project_id, user_id, session) if data.name is not None: project.name = data.name @@ -224,7 +148,8 @@ async def update_project( ) async def delete_project( project_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> Response: """Delete a project and all its repositories. @@ -237,13 +162,9 @@ async def delete_project( Returns: Empty response with 204 status code. """ - await _get_user(session, user_id) - project = await _get_owned_project(project_id, user_id, session) # Delete repositories from disk and database - result = await session.execute( - select(GitRepository).where(GitRepository.project_id == project_id) - ) + result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id)) repositories = result.scalars().all() for repo in repositories: if os.path.exists(repo.path): @@ -264,7 +185,8 @@ async def delete_project( async def set_default_ssh_key( project_id: uuid.UUID, data: SetDefaultSSHKeyRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> Project: """Set the default SSH key for a project. @@ -278,8 +200,6 @@ async def set_default_ssh_key( Returns: The updated project. """ - user = await _get_user(session, user_id) - project = await _get_owned_project(project_id, user_id, session) ssh_key = await session.get(SSHKey, data.ssh_key_id) if ssh_key is None or ssh_key.user_id != user.id: diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py index a18a96c..1a10187 100644 --- a/apps/api/src/api/ssh_keys.py +++ b/apps/api/src/api/ssh_keys.py @@ -1,4 +1,3 @@ -import base64 import uuid from datetime import datetime @@ -6,17 +5,19 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import get_current_user, get_db_session from src.config import Settings from src.models.ssh_key import SSHKey +from src.models.user import User +from src.schemas.ssh_key import SSHKeyCreate, SSHKeyResponse router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) + def _get_fernet() -> Fernet: """Generate a valid Fernet key from the session secret.""" import base64 @@ -53,36 +54,6 @@ def generate_ssh_key_pair() -> tuple[str, str]: return private_bytes.decode("utf-8"), public_bytes.decode("utf-8") -class SSHKeyCreate(BaseModel): - name: str - - -class SSHKeyResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - public_key: str - created_at: datetime - - -class SignPayloadRequest(BaseModel): - payload: str - - -class SignatureResponse(BaseModel): - signature: str - - -class VerifySignatureRequest(BaseModel): - payload: str - signature: str - - -class VerifySignatureResponse(BaseModel): - valid: bool - - @router.post( "", response_model=SSHKeyResponse, @@ -92,7 +63,7 @@ class VerifySignatureResponse(BaseModel): ) async def create_ssh_key( data: SSHKeyCreate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> SSHKey: """Create a new SSH key pair. @@ -105,7 +76,6 @@ async def create_ssh_key( Returns: The newly created SSH key with public key exposed. """ - user = await _get_user(session, user_id) private_key, public_key = generate_ssh_key_pair() fernet = _get_fernet() @@ -130,7 +100,7 @@ async def create_ssh_key( description="List all SSH keys for the authenticated user.", ) async def list_ssh_keys( - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> list[SSHKey]: """List all SSH keys for the authenticated user. @@ -142,7 +112,6 @@ async def list_ssh_keys( Returns: List of SSH keys owned by the user. """ - user = await _get_user(session, user_id) result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id)) return list(result.scalars().all()) @@ -155,7 +124,7 @@ async def list_ssh_keys( ) async def delete_ssh_key( key_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> None: """Delete an SSH key. @@ -168,87 +137,9 @@ async def delete_ssh_key( Returns: None with 204 status code. """ - user = await _get_user(session, user_id) ssh_key = await session.get(SSHKey, key_id) if ssh_key is None or ssh_key.user_id != user.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") await session.delete(ssh_key) await session.commit() - - -@router.post( - "/{key_id}/sign", - response_model=SignatureResponse, - summary="Sign payload", - description="Sign a payload using the SSH private key.", -) -async def sign_payload( - key_id: uuid.UUID, - data: SignPayloadRequest, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> SignatureResponse: - """Sign a payload with an SSH key. - - Args: - key_id: UUID of the SSH key to use for signing. - data: Sign request containing the payload string. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Base64-encoded Ed25519 signature. - """ - user = await _get_user(session, user_id) - ssh_key = await session.get(SSHKey, key_id) - if ssh_key is None or ssh_key.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") - - fernet = _get_fernet() - private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() - - private_key = serialization.load_ssh_private_key( - private_key_pem.encode(), password=None - ) - - signature = private_key.sign(data.payload.encode()) - return SignatureResponse(signature=base64.b64encode(signature).decode()) - - -@router.post( - "/{key_id}/verify", - response_model=VerifySignatureResponse, - summary="Verify signature", - description="Verify a signature against a payload using the SSH public key.", -) -async def verify_signature( - key_id: uuid.UUID, - data: VerifySignatureRequest, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> VerifySignatureResponse: - """Verify a signature with an SSH key's public key. - - Args: - key_id: UUID of the SSH key to use for verification. - data: Verify request containing payload and base64-encoded signature. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Whether the signature is valid. - """ - user = await _get_user(session, user_id) - ssh_key = await session.get(SSHKey, key_id) - if ssh_key is None or ssh_key.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") - - public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode()) - - try: - signature = base64.b64decode(data.signature) - public_key.verify(signature, data.payload.encode()) - return VerifySignatureResponse(valid=True) - except Exception: - return VerifySignatureResponse(valid=False) diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index c2f2a8d..0ee52e4 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -1,102 +1,65 @@ """WebSocket terminal endpoint for tool instances.""" import asyncio -import json import logging import uuid -from contextlib import suppress -from fastapi import APIRouter, Depends, HTTPException, WebSocket, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, WebSocket from sqlalchemy.ext.asyncio import AsyncSession -from starlette.websockets import WebSocketDisconnect -from src.auth.dependencies import get_current_user_id, get_db_session -from src.models.terminal_session import TerminalSessionModel +from src.auth.dependencies import get_db_session from src.models.tool_instance import ToolInstance -from src.models.tool_type import ToolType -from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager +from src.services.terminal_manager import terminal_manager router = APIRouter() logger = logging.getLogger(__name__) -class SessionRef: - """Mutable reference to a terminal session, allowing updates during reset.""" - - def __init__(self, session, slot_session_id: str | None = None): - self.session = session - self.slot_session_id = slot_session_id or session.session_id - - @router.websocket( "/ws/tool-instances/{instance_id}/terminal", ) -async def terminal_websocket_default( +async def terminal_websocket( websocket: WebSocket, instance_id: str, db_session: AsyncSession = Depends(get_db_session), ) -> None: - """WebSocket endpoint for terminal access (default session alias). + """WebSocket endpoint for terminal access to a tool instance. - Backward-compatible route that maps to the default session. - """ - await _handle_terminal_websocket(websocket, instance_id, None, db_session) - - -@router.websocket( - "/ws/tool-instances/{instance_id}/terminal/{session_id}", -) -async def terminal_websocket_specific( - websocket: WebSocket, - instance_id: str, - session_id: str, - db_session: AsyncSession = Depends(get_db_session), -) -> None: - """WebSocket endpoint for a specific terminal session.""" - await _handle_terminal_websocket(websocket, instance_id, session_id, db_session) - - -async def _handle_terminal_websocket( - websocket: WebSocket, - instance_id: str, - target_session_id: str | None, - db_session: AsyncSession, -) -> None: - """Shared WebSocket handler for terminal sessions. + Provides an interactive terminal session inside a running tool instance container. + Supports: + - Auto-reconnection (client reconnects, server spawns new session) + - Heartbeat ping/pong + - Binary and text input frames + - Graceful session end notifications Args: websocket: The WebSocket connection. instance_id: UUID string of the tool instance. - target_session_id: Specific session ID (slot key). None means default session. db_session: Database session. + + Returns: + None. Communicates via WebSocket messages. + """ - logger.debug( - "Terminal WebSocket connection attempt for instance %s (session=%s)", - instance_id, - target_session_id or "default", - ) + logger.info("Terminal WebSocket connection attempt for instance %s", instance_id) await websocket.accept() - logger.debug("Terminal WebSocket accepted for instance %s", instance_id) try: - # Parse instance_id instance_uuid = uuid.UUID(instance_id) except ValueError: logger.error("Invalid instance ID: %s", instance_id) await websocket.close(code=4001, reason="Invalid instance ID") return - # Authenticate user from session cookie user_id = await _get_user_from_websocket(websocket, db_session) if user_id is None: logger.warning( - "Unauthorized terminal access attempt for instance %s", instance_id + "Unauthorized terminal access attempt for instance %s", + instance_id, ) await websocket.close(code=4003, reason="Unauthorized") return - # Get instance and verify ownership instance = await db_session.get(ToolInstance, instance_uuid) if instance is None: logger.warning("Instance %s not found", instance_id) @@ -122,605 +85,50 @@ async def _handle_terminal_websocket( await websocket.close(code=4004, reason="Instance not running") return - logger.debug("Terminal auth passed for instance %s, user %s", instance_id, user_id) - - # Verify the container actually exists (may have been removed/recreated) - from src.services.docker import get_container_status - - container_status = get_container_status(instance.container_id) - if container_status["status"] == "not_found": - logger.error( - "Container %s for instance %s not found (may have been removed)", - instance.container_id, - instance_id, - ) - await websocket.close( - code=4004, reason="Container not found — restart the tool instance" - ) - return - - # Fetch tool type to get startup_command - tool_type = await db_session.get(ToolType, instance.tool_type_id) - startup_command = tool_type.startup_command if tool_type else None - if startup_command: - logger.debug( - "Using startup command for instance %s: %s", - instance_id, - startup_command, - ) - - session = None - - # Get or create terminal session + logger.info( + "Creating terminal session for instance %s (container_id=%s)", + instance_id, + instance.container_id, + ) try: - if target_session_id is None: - # Default session alias - session = await terminal_manager.get_or_create_session( - instance_uuid, - instance.container_id, - startup_command=startup_command, - ) - slot_session_id = "default" - else: - # Specific session - session = terminal_manager.get_session( - instance_id, - target_session_id, - ) - if session is None: - # Session not in memory — may have been lost on server restart. - # Try to restore from the DB row. - db_row = await db_session.get( - TerminalSessionModel, uuid.UUID(target_session_id) - ) - if ( - db_row is not None - and db_row.instance_id == instance_uuid - and db_row.status != "closed" - ): - logger.info( - "Restoring terminal session %s for instance %s from DB", - target_session_id, - instance_id, - ) - session = await terminal_manager.create_session( - instance_uuid, - instance.container_id, - startup_command=startup_command, - name=db_row.name, - session_id=target_session_id, - ) - else: - logger.warning( - "Session %s not found for instance %s", - target_session_id, - instance_id, - ) - await websocket.close(code=4004, reason="Session not found") - return - # Determine slot key for reset scoping - key = terminal_manager._find_key_by_internal_id( - instance_id, session.session_id - ) - slot_session_id = key[1] if key else target_session_id - - logger.debug( - "Terminal session ready for instance %s (session_id=%s, slot=%s)", - instance_id, - session.session_id, - slot_session_id, + session = await terminal_manager.create_session( + instance_uuid, + instance.container_id, + websocket, + ) + logger.info( + "Terminal session created successfully for instance %s", + instance_id, ) - - # Attach WebSocket to session - await terminal_manager.attach_websocket(session, websocket) - logger.debug("WebSocket attached to session for instance %s", instance_id) # Send connected status await websocket.send_json({"type": "status", "status": "connected"}) - logger.debug("Sent connected status for instance %s", instance_id) - # Use mutable session reference so loops can survive reset - session_ref = SessionRef(session, slot_session_id) - - # Start write loop and heartbeat (read is now event-driven in TerminalSession) - write_task = asyncio.create_task( - _write_loop(session_ref, websocket, instance_id) - ) - heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket)) - logger.debug("Started terminal loops for instance %s", instance_id) - - # Wait for either task to complete (indicating disconnect or error) - done, pending = await asyncio.wait( - [write_task, heartbeat_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - logger.debug( - "Terminal loop completed for instance %s, done=%s", - instance_id, - len(done), - ) - - # Cancel remaining tasks - for task in pending: - task.cancel() - - except WebSocketDisconnect: - logger.debug("WebSocket disconnected for instance %s", instance_id) - except Exception as exc: - logger.error( - "Terminal session error for instance %s: %s", - instance_id, - str(exc), - exc_info=True, - ) - with suppress(Exception): - await websocket.close(code=4000, reason=f"Error: {exc}") - finally: - # Detach WebSocket, don't kill session - with suppress(Exception): - if session is not None: - await terminal_manager.detach_websocket(session, websocket) - logger.debug( - "WebSocket detached from session for instance %s", instance_id + # Monitor session health and echo state + while session.is_alive() and not session.closed: + # Check echo state periodically + new_echo_state = await session.check_echo_state() + if new_echo_state is not None: + await websocket.send_json( + {"type": "set_echo_state", "enabled": new_echo_state}, ) + await asyncio.sleep(1.0) + # Session ended — determine reason and notify client + exit_reason = session.get_exit_reason() or "process_exit" + await websocket.send_json({"type": "session_ended", "reason": exit_reason}) + await websocket.close(code=1000, reason=f"Session ended: {exit_reason}") -async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None: - """Read input from WebSocket and send to container.""" - try: - while True: - session = session_ref.session - if not session.is_alive() or session._closed: - await asyncio.sleep(0.1) - continue - message = await websocket.receive() - if message["type"] == "websocket.receive": - if "bytes" in message: - await session.write_input(message["bytes"]) - elif "text" in message: - text = message["text"] - if text.startswith("{"): - # Control message (JSON) - try: - ctrl = json.loads(text) - msg_type = ctrl.get("type") - - if msg_type == "resize": - cols = ctrl.get("cols", 80) - rows = ctrl.get("rows", 24) - logger.debug( - "Received resize message for instance %s: %sx%s", - instance_id, - cols, - rows, - ) - await session.resize(cols, rows) - elif msg_type == "ack": - char_count = ctrl.get("chars", 0) - if char_count > 0: - session.acknowledge_data(char_count) - elif msg_type == "reset": - # Reset terminal session (scoped to current slot) - logger.debug( - "Resetting terminal session for instance %s (slot=%s)", - session.instance_id, - session_ref.slot_session_id, - ) - await websocket.send_json( - {"type": "status", "status": "resetting"} - ) - - # Reset the session scoped to its slot - new_session = await terminal_manager.reset_session( - session.instance_id, - session.container_id, - startup_command=session.startup_command, - session_id=session_ref.slot_session_id, - name=session.name, - ) - - # Update the mutable session reference - session_ref.session = new_session - - # Attach to new session - await terminal_manager.attach_websocket( - new_session, websocket - ) - await websocket.send_json( - {"type": "status", "status": "connected"} - ) - - # Continue the loop with the new session - continue - - except json.JSONDecodeError: - # Not a valid JSON control message, treat as regular input - await session.write_input(text.encode("utf-8")) - else: - await session.write_input(text.encode("utf-8")) - elif message["type"] == "websocket.disconnect": - break except Exception: + logger.exception( + "Terminal session error for instance %s", + instance_id, + ) + await websocket.close(code=4000, reason="Terminal session error") + finally: pass -async def _heartbeat_loop(websocket: WebSocket) -> None: - """Send periodic ping messages to detect disconnections.""" - try: - while True: - await asyncio.sleep(30) # Ping every 30 seconds - try: - await websocket.send_json({"type": "ping"}) - except Exception: - # WebSocket is closed or broken - break - except Exception: - pass - - -async def _get_terminal_instance( - instance_id: uuid.UUID, - user_id: uuid.UUID, - db_session: AsyncSession, -) -> ToolInstance: - """Fetch instance and validate auth, ownership, and running status. - - Args: - instance_id: UUID of the tool instance. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - The validated ToolInstance. - - Raises: - HTTPException: If instance not found, not owned, or not running. - """ - instance = await db_session.get(ToolInstance, instance_id) - if instance is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found" - ) - - if instance.owner_id != user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to access this instance", - ) - - if instance.status != "running" or not instance.container_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running" - ) - - return instance - - -@router.get( - "/instances/{instance_id}/terminal/sessions", - summary="List terminal sessions", - description="List terminal sessions for a tool instance with live WebSocket state.", -) -async def list_terminal_sessions( - instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """List terminal sessions for an instance. - - Args: - instance_id: UUID of the tool instance. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with sessions list. - """ - await _get_terminal_instance(instance_id, user_id, db_session) - - # Query active DB rows for this instance - result = await db_session.execute( - select(TerminalSessionModel) - .where(TerminalSessionModel.instance_id == instance_id) - .where(TerminalSessionModel.status != "closed") - .order_by(TerminalSessionModel.created_at.asc()) - ) - db_rows = result.scalars().all() - - # Build response with live has_websockets flag. - # Include DB rows even without in-memory counterparts (e.g. after - # server restart) so the frontend can display tabs and reconnect. - sessions = [] - for row in db_rows: - live_session = terminal_manager.get_session(str(instance_id), str(row.id)) - sessions.append( - { - "id": str(row.id), - "name": row.name, - "status": row.status, - "has_websockets": live_session.has_websockets() - if live_session - else False, - "created_at": row.created_at.isoformat() if row.created_at else None, - "last_activity_at": row.last_activity_at.isoformat() - if row.last_activity_at - else None, - } - ) - - return {"sessions": sessions} - - -@router.post( - "/instances/{instance_id}/terminal/sessions", - summary="Create terminal session", - description="Create a new terminal session for a running tool instance.", - status_code=status.HTTP_201_CREATED, -) -async def create_terminal_session( - instance_id: uuid.UUID, - data: dict, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Create a new terminal session. - - Args: - instance_id: UUID of the tool instance. - data: Request body with optional name. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with new session details. - - Raises: - HTTPException: 409 if max sessions reached. - """ - instance = await _get_terminal_instance(instance_id, user_id, db_session) - assert instance.container_id is not None - - # Fetch tool type to get startup_command - tool_type = await db_session.get(ToolType, instance.tool_type_id) - startup_command = tool_type.startup_command if tool_type else None - - name = data.get("name") - - try: - session = await terminal_manager.create_session( - instance_id, - instance.container_id, - startup_command=startup_command, - name=name, - ) - except MaxSessionsExceededError: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Maximum of 5 terminal sessions reached for this instance", - ) from None - - return { - "id": session.session_id, - "name": session.name, - "status": session.status, - "created_at": session.last_activity, - } - - -@router.delete( - "/instances/{instance_id}/terminal/sessions/{session_id}", - summary="Close terminal session", - description="Close a specific terminal session.", -) -async def close_terminal_session( - instance_id: uuid.UUID, - session_id: str, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Close a terminal session. - - Args: - instance_id: UUID of the tool instance. - session_id: ID of the session to close. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with closure status. - """ - await _get_terminal_instance(instance_id, user_id, db_session) - - # Find the session by internal ID to determine its slot key - key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id) - if ( - key is None - and terminal_manager.get_session(str(instance_id), session_id) is not None - ): - key = (str(instance_id), session_id) - - if key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" - ) - - await terminal_manager.close_session(key[0], key[1]) - - return {"status": "closed", "session_id": session_id} - - -@router.post( - "/instances/{instance_id}/terminal/sessions/{session_id}/reset", - summary="Reset terminal session", - description="Reset a specific terminal session, killing the current shell and starting fresh.", -) -async def reset_specific_terminal_session( - instance_id: uuid.UUID, - session_id: str, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Reset a specific terminal session. - - Args: - instance_id: UUID of the tool instance. - session_id: ID of the session to reset. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with reset session details. - """ - instance = await _get_terminal_instance(instance_id, user_id, db_session) - assert instance.container_id is not None - - # Determine slot key for reset - key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id) - if ( - key is None - and terminal_manager.get_session(str(instance_id), session_id) is not None - ): - key = (str(instance_id), session_id) - - if key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" - ) - - # Fetch tool type to get startup_command - tool_type = await db_session.get(ToolType, instance.tool_type_id) - startup_command = tool_type.startup_command if tool_type else None - - # Preserve name if possible - live_session = terminal_manager.get_session(str(instance_id), session_id) - name = live_session.name if live_session else None - - new_session = await terminal_manager.reset_session( - instance_id, - instance.container_id, - startup_command=startup_command, - session_id=key[1], - name=name, - ) - - return { - "id": new_session.session_id, - "name": new_session.name, - "status": new_session.status, - } - - -@router.post( - "/instances/{instance_id}/terminal/sessions/{session_id}/rename", - summary="Rename terminal session", - description="Rename a specific terminal session.", -) -async def rename_terminal_session( - instance_id: uuid.UUID, - session_id: str, - data: dict, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Rename a terminal session. - - Args: - instance_id: UUID of the tool instance. - session_id: ID of the session to rename. - data: Request body with new name. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with updated session details. - """ - await _get_terminal_instance(instance_id, user_id, db_session) - - new_name = data.get("name") - if not new_name or not isinstance(new_name, str): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required" - ) - - # Update in-memory session name if live - live_session = terminal_manager.get_session(str(instance_id), session_id) - if live_session: - live_session.name = new_name - - # Update DB row - db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id)) - if db_row is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Session not found" - ) - - db_row.name = new_name - await db_session.commit() - - return {"id": str(db_row.id), "name": new_name} - - -@router.post( - "/instances/{instance_id}/terminal/reset", - summary="Reset terminal session (legacy alias)", - description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.", -) -async def reset_terminal_session( - instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), - db_session: AsyncSession = Depends(get_db_session), -) -> dict: - """Reset the default terminal session for an instance (legacy alias). - - Args: - instance_id: UUID of the tool instance. - user_id: ID of the authenticated user. - db_session: Database session. - - Returns: - Dictionary with status message. - """ - instance = await _get_terminal_instance(instance_id, user_id, db_session) - assert instance.container_id is not None - - # Fetch tool type to get startup_command - tool_type = await db_session.get(ToolType, instance.tool_type_id) - startup_command = tool_type.startup_command if tool_type else None - - try: - # Reset the default session - new_session = await terminal_manager.reset_session( - instance_id, - instance.container_id, - startup_command=startup_command, - ) - - logger.info( - "Terminal session reset for instance %s (new session_id=%s)", - instance_id, - new_session.session_id, - ) - - return { - "status": "success", - "message": "Terminal session reset successfully", - "instance_id": str(instance_id), - "session_id": new_session.session_id, - } - except Exception as exc: - logger.error( - "Failed to reset terminal session for instance %s: %s", - instance_id, - str(exc), - exc_info=True, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to reset terminal session: {exc}", - ) from exc - - async def _get_user_from_websocket( websocket: WebSocket, db_session: AsyncSession, @@ -733,6 +141,7 @@ async def _get_user_from_websocket( Returns: The user's UUID if authenticated, None otherwise. + """ from src.auth.session import decode_session_cookie from src.config import Settings diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py new file mode 100644 index 0000000..db8cb48 --- /dev/null +++ b/apps/api/src/api/tool_configs.py @@ -0,0 +1,213 @@ +"""Tool configuration API endpoints.""" + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.tool_config import ToolConfig +from src.models.tool_type import ToolType +from src.schemas.tool_config import ToolConfigCreate, ToolConfigUpdate, ToolConfigResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tool-configs", tags=["tool-configs"]) + + +@router.get("", summary="List tool configs", description="Get all tool configs for the current user.") +async def list_configs( + tool_type_id: str | None = None, + project_id: str | None = None, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> list: + """List tool configs for the current user.""" + query = select(ToolConfig).where(ToolConfig.user_id == user_id) + + if tool_type_id: + query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id)) + if project_id: + query = query.where(ToolConfig.project_id == uuid.UUID(project_id)) + else: + # If no project specified, get only global configs (project_id is None) + query = query.where(ToolConfig.project_id.is_(None)) + + result = await session.execute(query) + configs = result.scalars().all() + + return [ + { + "id": str(c.id), + "tool_type_id": str(c.tool_type_id), + "project_id": str(c.project_id) if c.project_id else None, + "key": c.key, + "value": c.value, + "config_type": c.config_type, + "file_path": c.file_path, + "port_override": c.port_override, + "start_command": c.start_command, + "working_directory": c.working_directory, + "environment_variables": c.environment_variables, + "volumes": c.volumes, + } + for c in configs + ] + + +@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED) +async def create_config( + data: ToolConfigCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a tool config.""" + # Verify tool type exists + tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id)) + if tool_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + + # Check for existing config with same key + query = select(ToolConfig).where( + ToolConfig.user_id == user_id, + ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id), + ToolConfig.key == data.key, + ) + if data.project_id: + query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id)) + else: + query = query.where(ToolConfig.project_id.is_(None)) + + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config with key '{data.key}' already exists" + ) + + config = ToolConfig( + user_id=user_id, + tool_type_id=uuid.UUID(data.tool_type_id), + project_id=uuid.UUID(data.project_id) if data.project_id else None, + key=data.key, + value=data.value, + config_type=data.config_type, + file_path=data.file_path, + port_override=data.port_override, + start_command=data.start_command, + working_directory=data.working_directory, + environment_variables=data.environment_variables, + volumes=data.volumes, + ) + session.add(config) + await session.commit() + await session.refresh(config) + + return { + "id": str(config.id), + "tool_type_id": str(config.tool_type_id), + "project_id": str(config.project_id) if config.project_id else None, + "key": config.key, + "value": config.value, + "config_type": config.config_type, + "file_path": config.file_path, + "port_override": config.port_override, + "start_command": config.start_command, + "working_directory": config.working_directory, + "environment_variables": config.environment_variables, + "volumes": config.volumes, + } + + +@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.") +async def update_config( + config_id: uuid.UUID, + data: ToolConfigUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a tool config.""" + config = await session.get(ToolConfig, config_id) + if config is None or config.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") + + if data.key is not None: + config.key = data.key + if data.value is not None: + config.value = data.value + if data.config_type is not None: + config.config_type = data.config_type + if data.file_path is not None: + config.file_path = data.file_path + if data.port_override is not None: + config.port_override = data.port_override + if data.start_command is not None: + config.start_command = data.start_command + if data.working_directory is not None: + config.working_directory = data.working_directory + if data.environment_variables is not None: + config.environment_variables = data.environment_variables + if data.volumes is not None: + config.volumes = data.volumes + + await session.commit() + await session.refresh(config) + + return { + "id": str(config.id), + "tool_type_id": str(config.tool_type_id), + "project_id": str(config.project_id) if config.project_id else None, + "key": config.key, + "value": config.value, + "config_type": config.config_type, + "file_path": config.file_path, + "port_override": config.port_override, + "start_command": config.start_command, + "working_directory": config.working_directory, + "environment_variables": config.environment_variables, + "volumes": config.volumes, + } + + +@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.") +async def get_default_configs( + tool_type_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get suggested default configs for a tool type.""" + tool_type = await session.get(ToolType, uuid.UUID(tool_type_id)) + if tool_type is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + + # Return suggested defaults based on required_variables + defaults = [] + for var in tool_type.required_variables: + defaults.append({ + "key": var, + "value": "", + "config_type": "env", + "description": f"Required variable: {var}", + }) + + return { + "tool_type_id": tool_type_id, + "suggested_configs": defaults, + } + + +@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.") +async def delete_config( + config_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Delete a tool config.""" + config = await session.get(ToolConfig, config_id) + if config is None or config.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found") + + await session.delete(config) + await session.commit() diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index eca5437..965c8ba 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1,1283 +1,115 @@ """Tool instance API endpoints.""" -import asyncio -import glob as glob_module import logging -import os -import subprocess import uuid -from datetime import datetime import httpx -from fastapi import ( - APIRouter, - APIRouter as FastAPIRouter, - Depends, - HTTPException, - Request, - Response, - status, -) -from pydantic import BaseModel, Field -from sqlalchemy import select +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import ( - _get_owned_project, - _get_user, - get_current_user_id, - get_db_session, -) -from src.services.event_bus import InstanceEventBus -from src.services.lifecycle_hooks import publish_lifecycle_event -from src.models.config_profile import ConfigProfile +from src.auth.dependencies import get_current_user, get_db_session, get_owned_project from src.models.git_repository import GitRepository from src.models.project import Project -from src.models.ssh_key import SSHKey from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType -from src.services.clone import check_dirty_state, clone_repository -from src.services.config_profile_resolver import ( - ConfigProfileCycleError, - ResolvedProfile, - apply_resolved_profile, - expand_container_path, - resolve_profile, -) -from src.services.docker import ( - connect_container_to_network, - ensure_instance_directory, - execute_compose_command, - find_free_port, - get_backend_network_name, - get_container_id, - get_container_ip_on_network, - get_container_logs, - get_container_status, - is_container_on_network, - render_compose_template, - sort_volumes_by_specificity, - wait_for_container_running, - write_compose_file, - write_config_files, - write_env_file, -) -from src.services.tunnel import ( - check_tunnel_health, - recreate_tunnel, - start_tunnel, - stop_tunnel, -) -from src.services.docker_build import build_image -from src.services.manifest_compiler import ( - compile_compose, - compile_dockerfile, - compile_entrypoint, - compute_image_tag, - deep_merge, - get_manifest_home_dir, - merge_with_config, - resolve_base, -) -from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions -from src.services.readiness_probe import execute_probe -from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files +from src.models.user import User +from src.schemas.tool_instance import CreateInstanceRequest +from src.services import instance_lifecycle as lifecycle +from src.services.docker import container as container_svc +from src.services.docker import tunnel as tunnel_svc logger = logging.getLogger(__name__) -_event_bus = InstanceEventBus() - - -async def _resolve_git_mounts( - session: AsyncSession, - resolved: ResolvedProfile, - instance_dir: str | None = None, - working_directory: str | None = None, - home_dir: str = "/root", -) -> list[dict]: - """Convert git mounts from resolved profile to Docker volume mounts. - - Looks up repository paths, auto-clones if needed, handles branch checkout, - expands glob patterns, and prepares bind mount entries. - Logs warnings for missing repos or invalid paths (non-blocking). - """ - if not resolved.git_mounts: - return [] - - # Process all git mounts concurrently - tasks = [] - for git_mount in resolved.git_mounts: - tasks.append( - _resolve_single_git_mount( - session, git_mount, instance_dir, working_directory, home_dir - ) - ) - - results = await asyncio.gather(*tasks, return_exceptions=True) - - volume_mounts = [] - for result in results: - if isinstance(result, Exception): - logger.warning("Git mount failed: %s", result) - continue - if result: - volume_mounts.extend(result) - - return volume_mounts - - -def _normalize_git_mount(entry: dict) -> dict: - """Normalize a git mount entry to the unified mappings format. - - Converts legacy source_path + target_path into a single-entry mappings array. - """ - entry = dict(entry) - if "mappings" not in entry or not entry.get("mappings"): - source = entry.get("source_path", ".") - target = entry.get("target_path") - if target is not None: - entry["mappings"] = [{"source_path": source, "target_path": target}] - entry.pop("source_path", None) - entry.pop("target_path", None) - return entry - - -def _clone_git_repo( - remote_url: str, - branch: str | None, - clone_parent: str, -) -> str: - """Clone or pull a git repository. - - Returns the path to the cloned repo (repo-clone directory). - """ - import hashlib - - url_hash = hashlib.md5(remote_url.encode()).hexdigest()[:12] - repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo" - clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}") - repo_path = os.path.join(clone_dir, "repo-clone") - - if not os.path.exists(repo_path): - try: - os.makedirs(clone_dir, exist_ok=True) - repo_path = clone_repository( - remote_url, - None, # No SSH key for now - can be added later - clone_dir, - branch or "main", - ) - logger.debug("Cloned git mount repository %s to %s", remote_url, repo_path) - except Exception as exc: - logger.warning("Clone failed for git mount %s: %s", remote_url, exc) - raise - else: - # Repo exists - pull latest updates - try: - _pull_repository_updates(repo_path, remote_url) - logger.debug("Pulled updates for git mount %s", remote_url) - except Exception as exc: - logger.warning("Failed to pull updates for %s: %s", remote_url, exc) - - # Handle branch checkout if specified - if branch and repo_path: - success = _checkout_branch(repo_path, branch) - if success: - logger.debug("Checked out branch %s for %s", branch, remote_url) - else: - logger.warning( - "Branch %s not found in %s, using current branch", branch, remote_url - ) - - return repo_path - - -def _resolve_git_mount_mappings( - repo_path: str, - mappings: list[dict], - working_directory: str | None, - home_dir: str = "/root", -) -> list[dict]: - """Resolve mappings from an already-cloned repo to volume mount entries. - - Returns a flat list of volume mount dicts. - """ - volume_mounts = [] - - for mapping in mappings: - source_path = mapping.get("source_path", ".") - target_path = mapping.get("target_path") - - if not target_path: - logger.warning("Invalid mapping skipped: missing target_path") - continue - - # Expand ~ and $HOME in target path - target_path = expand_container_path(target_path, home_dir) - - # Resolve relative target paths against working directory - final_target = target_path - if not target_path.startswith("/"): - if not working_directory: - logger.warning( - "Git mount skipped: target_path '%s' is relative but no working_directory is configured. " - "Set working_directory in the tool config or use an absolute path.", - target_path, - ) - continue - final_target = os.path.join(working_directory, target_path) - - # Build source path and expand globs - if source_path and source_path != ".": - source_full = os.path.join(repo_path, source_path) - else: - source_full = repo_path - - # Expand glob patterns - matched_paths = _expand_glob_source(source_full, repo_path) - - if not matched_paths: - logger.warning( - "Git mount skipped: no files matched source path %s in repo", - source_path, - ) - continue - - for matched_path in matched_paths: - if not os.path.exists(matched_path): - continue - - # Determine target path for this match - if len(matched_paths) == 1: - # Single match: mount directly to target_path - mount_target = final_target - else: - # Multiple matches: append relative path to target - rel_path = os.path.relpath(matched_path, repo_path) - mount_target = os.path.join(final_target, rel_path) - - volume_mounts.append( - { - "source": matched_path, - "target": mount_target, - "type": "bind", - } - ) - logger.debug( - "Added git mount: %s -> %s", - matched_path, - mount_target, - ) - - return volume_mounts - - -async def _resolve_single_git_mount( - session: AsyncSession, - git_mount: dict, - instance_dir: str | None = None, - working_directory: str | None = None, - home_dir: str = "/root", -) -> list[dict]: - """Resolve a single git mount to volume mount entries. - - Clones directly from remote_url, no database lookup needed. - Returns a list of volume mounts (one for each matched file/directory). - """ - git_mount = _normalize_git_mount(git_mount) - remote_url = git_mount.get("remote_url") - branch = git_mount.get("branch") - mappings = git_mount.get("mappings", []) - - if not remote_url: - logger.warning("Invalid git mount skipped: missing remote_url") - return [] - - if not mappings: - logger.warning("Invalid git mount skipped: no mappings") - return [] - - if not instance_dir: - logger.warning("Git mount skipped: no instance_dir provided for cloning") - return [] - - # Clone or pull the repository - try: - repo_path = await asyncio.to_thread( - _clone_git_repo, remote_url, branch, instance_dir - ) - except Exception: - return [] - - # Resolve all mappings from the cloned repo - return _resolve_git_mount_mappings(repo_path, mappings, working_directory, home_dir) - - -def _checkout_branch(repo_path: str, branch: str) -> bool: - """Checkout a specific branch in a git repository. - - Returns True if checkout succeeded, False if it failed. - On failure, the repository remains on its current branch. - """ - import subprocess - - # First try to checkout existing branch - result = subprocess.run( - ["git", "-C", repo_path, "checkout", branch], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - # Try fetching and checking out - subprocess.run( - ["git", "-C", repo_path, "fetch", "origin", branch], - capture_output=True, - text=True, - ) - result = subprocess.run( - ["git", "-C", repo_path, "checkout", "-b", branch, f"origin/{branch}"], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - logger.warning( - "Failed to checkout branch %s in %s: %s", - branch, - repo_path, - result.stderr.strip(), - ) - return False - - return True - - -def _pull_repository_updates(repo_path: str, remote_url: str) -> None: - """Pull latest updates from remote repository. - - Used when starting a new container with an existing cloned repository - to ensure the latest code is mounted. - """ - import subprocess - - # Fetch latest changes - result = subprocess.run( - ["git", "-C", repo_path, "fetch", "origin"], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - raise RuntimeError(f"Failed to fetch updates: {result.stderr}") - - # Pull changes for current branch - result = subprocess.run( - ["git", "-C", repo_path, "pull", "origin"], - capture_output=True, - text=True, - ) - - if result.returncode != 0: - raise RuntimeError(f"Failed to pull updates: {result.stderr}") - - -def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: - """Expand glob patterns in source path. - - Returns a list of matched absolute paths. - Limits results to prevent abuse. - """ - MAX_GLOB_MATCHES = 100 - - # Check if path contains glob characters - if not any(c in source_path for c in "*?["): - # No glob pattern: return single path if it exists - return [source_path] if os.path.exists(source_path) else [] - - # Expand glob pattern - matched = glob_module.glob(source_path, recursive=True) - total_matched = len(matched) - - # Filter to only paths within the repo and limit count - results = [] - for path in matched: - abs_path = os.path.abspath(path) - if abs_path.startswith(os.path.abspath(repo_path)): - results.append(abs_path) - if len(results) >= MAX_GLOB_MATCHES: - logger.warning( - "Glob pattern matched %d files, limited to %d", - total_matched, - MAX_GLOB_MATCHES, - ) - break - - return results - - router = APIRouter(prefix="/projects", tags=["tool-instances"]) - - -class CreateInstanceRequest(BaseModel): - """Request body for creating a tool instance.""" - - model_config = {"extra": "ignore"} - - tool_type_id: str = Field(description="UUID of the tool type to instantiate") - display_name: str | None = Field( - default=None, description="Optional display name for the instance" - ) - workspace_id: str | None = Field( - default=None, description="UUID of workspace to mount (replaces clone_mode)" - ) - 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" - ) - ssh_key_ids: list[str] = Field( - default_factory=list, description="SSH key IDs to mount into container ~/.ssh" - ) - - -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" - ) - ssh_key_ids: list[str] = Field( - default_factory=list, description="SSH key IDs to mount into container ~/.ssh" - ) - - -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 not profile_id: - 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 _sanitize_compose_file(compose_path: str) -> None: - """Remove invalid port mappings (target port 0) from compose file.""" - import yaml - from pathlib import Path - - compose_file = Path(compose_path) - if not compose_file.exists(): - return - - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - modified = False - for service_name, service_config in compose_data["services"].items(): - if "ports" in service_config: - valid_ports = [] - for port_mapping in service_config["ports"]: - if isinstance(port_mapping, str) and ":" in port_mapping: - parts = port_mapping.split(":") - if len(parts) == 2: - host_port, container_port = parts - # Skip invalid mappings (target port 0 or empty) - if container_port == "0" or not container_port: - modified = True - continue - valid_ports.append(port_mapping) - - if valid_ports: - service_config["ports"] = valid_ports - else: - del service_config["ports"] - modified = True - break # Only check first service - - if modified: - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - - -def _modify_compose_file( - compose_path: str, - port_override: int | None = None, - start_command: str | None = None, - working_directory: str | None = None, - extra_volumes: list[dict] | None = None, - home_dir: str = "/root", -) -> None: - """Modify compose file with runtime overrides.""" - import yaml - from pathlib import Path - - compose_file = Path(compose_path) - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - # Apply modifications to the first service - for service_name, service_config in compose_data["services"].items(): - if port_override and "ports" in service_config: - # Update port mapping - for i, port_mapping in enumerate(service_config["ports"]): - if isinstance(port_mapping, str) and ":" in port_mapping: - host_port, container_port = port_mapping.split(":", 1) - service_config["ports"][i] = f"{port_override}:{container_port}" - break - - if start_command: - service_config["command"] = start_command - - if working_directory: - service_config["working_dir"] = expand_container_path( - working_directory, home_dir - ) - - if extra_volumes: - if "volumes" not in service_config: - service_config["volumes"] = [] - for vol in extra_volumes: - source = vol.get("source", "") - target = expand_container_path(vol.get("target", ""), home_dir) - vol_type = vol.get("type", "bind") - if vol_type == "bind": - service_config["volumes"].append(f"{source}:{target}") - else: - service_config["volumes"].append(f"{source}:{target}:{vol_type}") - - # Sort volumes so parent paths come before child paths - if service_config.get("volumes"): - service_config["volumes"] = sort_volumes_by_specificity( - service_config["volumes"] - ) - - break # Only modify the first service - - # Write back - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - - -def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None: - """Ensure compose file has explicit container_name for predictable naming. - - Docker Compose auto-generates container names from the project directory - when container_name is absent. This breaks tunnel connectivity because - get_container_name(instance.name) cannot find the container. We inject - container_name into every service so the container has a predictable name. - """ - import yaml - from pathlib import Path - - compose_file = Path(compose_path) - if not compose_file.exists(): - return - - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - modified = False - for svc_name, svc_config in compose_data["services"].items(): - if "container_name" not in svc_config: - svc_config["container_name"] = container_name.lower() - modified = True - - if modified: - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - logger.info( - "Injected container_name '%s' into compose file", - container_name.lower(), - ) - - -def _ensure_web_bind_address( - compose_path: str, tool_type_name: str, default_port: int -) -> None: - """Auto-inject bind address for known web tools that default to 127.0.0.1. - - Many web tools (code-server, jupyter) bind to localhost by default, - making them inaccessible from the Docker network. This function detects - known tool images and injects the correct --bind-addr or --ip flag. - """ - import yaml - from pathlib import Path - - if default_port <= 0: - return - - KNOWN_BIND_FIXES: dict[str, str] = { - "code-server": f"--bind-addr 0.0.0.0:{default_port}", - "jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser", - } - - bind_command = KNOWN_BIND_FIXES.get(tool_type_name) - if not bind_command: - return - - compose_file = Path(compose_path) - if not compose_file.exists(): - return - - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - for service_config in compose_data["services"].values(): - image = service_config.get("image", "") - if not image: - continue - - # LSIO images already bind to 0.0.0.0 — command override breaks s6 init - if "linuxserver" in image: - existing_command = service_config.get("command", "") - if "--bind-addr" in existing_command or "--host" in existing_command: - del service_config["command"] - compose_file.write_text( - yaml.dump(compose_data, default_flow_style=False) - ) - logger.warning( - "Removed broken command override from LSIO image: %s", - existing_command, - ) - return - return - - # Check if the image matches a known tool - is_code_server = tool_type_name == "code-server" and ( - "code-server" in image or "coder" in image - ) - is_jupyter = tool_type_name == "jupyter-notebook" and ( - "jupyter" in image or "notebook" in image - ) - if not is_code_server and not is_jupyter: - continue - - existing_command = service_config.get("command", "") - if existing_command: - # Already correct — nothing to do - if bind_command in existing_command: - return - # Fix broken or outdated bind flags - if ( - "--bind-addr" in existing_command - or "--host" in existing_command - or "--ip=" in existing_command - ): - service_config["command"] = bind_command - compose_file.write_text( - yaml.dump(compose_data, default_flow_style=False) - ) - logger.warning( - "Replaced broken bind address for %s: %s → %s", - tool_type_name, - existing_command, - bind_command, - ) - return - # Some other command override exists — don't touch it - return - - # No command yet — inject the correct bind address - service_config["command"] = bind_command - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) - return - - -def _ensure_backend_network_in_compose(compose_path: str) -> None: - """Inject the backend network into the compose file so compose up attaches it. - - Instead of running 'docker network connect' after container creation (which - is prone to race conditions and silent failures), we declare the network in - the compose file itself. Docker Compose then connects the container to the - network atomically during 'docker compose up'. - """ - import yaml - from pathlib import Path - - compose_file = Path(compose_path) - if not compose_file.exists(): - return - - content = compose_file.read_text() - compose_data = yaml.safe_load(content) - - if not compose_data or "services" not in compose_data: - return - - network_name = get_backend_network_name() - modified = False - - for svc_config in compose_data["services"].values(): - existing = svc_config.get("networks", []) - if network_name not in existing: - svc_config["networks"] = existing + [network_name] - modified = True - break # Only modify first service - - # Declare the network as external at the top level - if "networks" not in compose_data: - compose_data["networks"] = {} - if network_name not in compose_data["networks"]: - compose_data["networks"][network_name] = {"external": True} - modified = True - - if modified: - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - logger.info("Injected backend network '%s' into compose file", network_name) - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances", - summary="Create tool instance", - description="Create a new tool instance for a repository.", -) +async def _get_instance(session: AsyncSession, instance_id: uuid.UUID, repo_id: uuid.UUID) -> ToolInstance: + instance = await session.get(ToolInstance, instance_id) + if instance is None or instance.repository_id != repo_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="instance not found") + return instance +async def _get_repo(session: AsyncSession, repo_id: uuid.UUID, project_id: uuid.UUID) -> GitRepository: + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + return repo +# ── Endpoints ────────────────────────────────────────────────────────────── + +@router.post("/{project_id}/repositories/{repo_id}/instances") async def create_instance( project_id: uuid.UUID, repo_id: uuid.UUID, data: CreateInstanceRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new tool instance for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - tool_type_id: UUID of the tool type to instantiate. - display_name: Optional display name for the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details. - """ - logger.debug( - "Creating instance: project_id=%s, repo_id=%s, tool_type_id=%s, display_name=%s", - project_id, - repo_id, - data.tool_type_id, - data.display_name, - ) - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - - tool_type_id = uuid.UUID(data.tool_type_id) - tool_type = await session.get(ToolType, tool_type_id) + """Create a new tool instance.""" + repo = await _get_repo(session, repo_id, project_id) + tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id)) if tool_type is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - # Validate config profile if provided - selected_profile_id = await _validate_config_profile( - session, data.config_profile_id, user_id, project_id, tool_type_id + selected_profile = None + if data.config_profile_id: + from src.models.config_profile import ConfigProfile + selected_profile = await session.get(ConfigProfile, uuid.UUID(data.config_profile_id)) + if selected_profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found") + if selected_profile.user_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="config profile does not belong to user") + + instance = await lifecycle.create_new_instance( + session, project, repo, tool_type, user, data.display_name, selected_profile ) - - # Resolve workspace if provided - workspace = None - workspace_id = None - if data.workspace_id: - from src.models.workspace import Workspace as WorkspaceModel - - try: - workspace_id = uuid.UUID(data.workspace_id) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid workspace_id format", - ) - workspace = await session.get(WorkspaceModel, workspace_id) - if workspace is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="workspace not found", - ) - if workspace.repo_id != repo_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="workspace does not belong to this repository", - ) - - try: - # Validate clone mode requirements (legacy path) - if data.clone_mode == "clone" and not workspace: - if not repo.remote_url: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository does not have a remote URL for cloning", - ) - if not repo.ssh_key_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository must have an SSH key assigned for clone mode", - ) - - # Generate unique name - instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" - instance_display = ( - data.display_name or f"{tool_type.display_name} - {repo.name}" - ) - - # Create instance directory - instance_dir = ensure_instance_directory(instance_name) - compose_path = os.path.join(instance_dir, "docker-compose.yml") - - # Find free port - tool_port = find_free_port() - - # Determine repo path based on workspace or clone mode - if workspace: - repo_path = workspace.path - elif data.clone_mode == "clone": - # Get SSH key for cloning - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - if ssh_key is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="repository SSH key not found", - ) - - # Prepare SSH key for clone operation - ssh_key_path = None - try: - ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key) - ssh_key_path = os.path.join(ssh_dir, "id_ed25519") - - # Clone repository - clone_path = clone_repository( - remote_url=repo.remote_url, - ssh_key_path=ssh_key_path, - instance_dir=instance_dir, - branch=data.branch or "main", - ) - repo_path = clone_path - except Exception as exc: - logger.exception("Failed to clone repository: %s", exc) - cleanup_ssh_key_files(instance_dir) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to clone repository: {exc}", - ) - else: - repo_path = repo.path - - # Verify cloned repo has files - if data.clone_mode == "clone" and repo_path: - try: - repo_contents = os.listdir(repo_path) - if not repo_contents or ( - len(repo_contents) == 1 and repo_contents[0] == ".git" - ): - logger.error("Cloned repository at %s appears empty", repo_path) - raise RuntimeError("Cloned repository is empty") - logger.debug( - "Verified cloned repo at %s has %d items", - repo_path, - len(repo_contents), - ) - except Exception as exc: - logger.exception("Failed to verify cloned repository: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Cloned repository verification failed: {exc}", - ) - - # Create new local branch if requested - if data.clone_mode == "clone" and data.new_branch: - try: - result = subprocess.run( - ["git", "-C", repo_path, "checkout", "-b", data.new_branch], - capture_output=True, - text=True, - ) - if result.returncode != 0: - logger.error( - "Failed to create branch %s: %s", data.new_branch, result.stderr - ) - raise RuntimeError(f"Failed to create branch: {result.stderr}") - logger.debug( - "Created local branch %s in cloned repository", data.new_branch - ) - except Exception as exc: - logger.exception("Failed to create local branch: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create local branch: {exc}", - ) - - # Handle based on definition type - if tool_type.definition_type == "dockerfile": - # Build image from Dockerfile - image_tag = f"headquarter/{instance_name}:latest".lower() - - if tool_type.dockerfile_template: - returncode, stdout, stderr = await asyncio.to_thread( - build_image, - instance_dir=instance_dir, - dockerfile=tool_type.dockerfile_template, - tag=image_tag, - build_context=tool_type.build_context, - ) - - if returncode != 0: - logger.error( - "Failed to build image for instance %s: %s", - instance_name, - stderr, - ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to build Docker image: {stderr[:500]}", - ) - - logger.info( - "Successfully built image %s for instance %s", - image_tag, - instance_name, - ) - - # Generate compose for dockerfile-built image - # Only include ports if tool requires one (skip for terminal-only tools) - ports_section = ( - f""" ports: - - "{tool_port}:{tool_type.default_port}" -""" - if tool_type.default_port and tool_type.default_port > 0 - else "" - ) - - compose_content = f"""version: "3.8" -services: - app: - image: {image_tag} - container_name: {instance_name.lower()} - stdin_open: true - tty: true -{ports_section} volumes: - - {repo_path}:/workspace - restart: unless-stopped -""" - write_compose_file(instance_dir, compose_content) - - elif tool_type.definition_type == "manifest": - # Manifest-based: generate compose only; image built lazily on start - from src.models.tool_definition_manifest import ToolDefinitionManifest - - manifest_def = await session.get( - ToolDefinitionManifest, tool_type.manifest_id - ) - if not manifest_def: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Manifest definition not found for this tool type", - ) - - manifest = dict(manifest_def.manifest) - if manifest_def.base_definition_id: - base_def = await session.get( - ToolDefinitionManifest, manifest_def.base_definition_id - ) - if base_def: - manifest = resolve_base( - deep_merge(dict(base_def.manifest), manifest) - ) - - image_tag = compute_image_tag(tool_type.name, manifest) - - variables = { - "IMAGE_TAG": image_tag, - "INSTANCE_NAME": instance_name.lower(), - "INSTANCE_DIR": instance_dir, - "REPO_PATH": repo_path, - "SSH_PATH": "", - "TOOL_PORT": tool_port, - "EXTRA_ENV": {}, - "EXTRA_VOLUMES": [], - } - compose_content = compile_compose(manifest, variables) - write_compose_file(instance_dir, compose_content) - - else: - # Render compose template (legacy) - variables = { - "REPO_PATH": repo_path, - "INSTANCE_NAME": instance_name, - "INSTANCE_ID": instance_name, - "TOOL_NAME": instance_name, - "TOOL_PORT": tool_port, - "USER_ID": str(user_id), - "PROJECT_ID": str(project_id), - } - compose_content = render_compose_template( - tool_type.compose_template, variables - ) - - # Safety check: for clone mode, ensure repo is mounted in compose file - if data.clone_mode == "clone" and repo_path: - import yaml - - compose_data = yaml.safe_load(compose_content) - repo_mounted = False - if compose_data and "services" in compose_data: - for svc in compose_data["services"].values(): - volumes = svc.get("volumes", []) - for vol in volumes: - vol_str = str(vol) - if repo_path in vol_str: - repo_mounted = True - break - if repo_mounted: - break - - if not repo_mounted: - logger.warning( - "Compose template for tool type %s does not mount repo path; adding default mount", - tool_type.name, - ) - # Add default mount to first service - if compose_data and "services" in compose_data: - for svc in compose_data["services"].values(): - if "volumes" not in svc: - svc["volumes"] = [] - svc["volumes"].append(f"{repo_path}:/workspace") - break - compose_content = yaml.dump( - compose_data, default_flow_style=False - ) - - write_compose_file(instance_dir, compose_content) - - # Create database record - instance = ToolInstance( - name=instance_name, - display_name=instance_display, - tool_type_id=tool_type_id, - repository_id=repo_id, - project_id=project_id, - owner_id=user_id, - status="pending", - compose_path=compose_path, - port=tool_port, - workspace_id=workspace_id, - 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, - ssh_key_ids=data.ssh_key_ids or None, - ) - session.add(instance) - await session.commit() - await session.refresh(instance) - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.created", - created_by=user_id, - status="pending", - message="Instance created", - ) - - return { - "id": str(instance.id), - "name": instance.name, - "display_name": instance.display_name, - "tool_type_id": str(instance.tool_type_id), - "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: - logger.exception("Failed to create instance: %s", exc) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create instance: {exc}", - ) - - -@router.get( - "/{project_id}/repositories/{repo_id}/instances", - summary="List instances", - description="List all tool instances for a repository.", -) + return { + "id": str(instance.id), + "name": instance.name, + "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), + "status": instance.status, + "config_profile_id": str(instance.selected_profile_id) if instance.selected_profile_id else None, + "created_at": instance.created_at.isoformat(), + } +@router.get("/{project_id}/repositories/{repo_id}/instances") async def list_instances( project_id: uuid.UUID, repo_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """List all instances for a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing list of instances. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="repository not found" - ) - + """List all tool instances for a repository.""" + await _get_repo(session, repo_id, project_id) + from sqlalchemy import select result = await session.execute( select(ToolInstance) - .where(ToolInstance.repository_id == repo_id) - .where(ToolInstance.owner_id == user_id) + .where(ToolInstance.repository_id == repo_id, ToolInstance.owner_id == user.id) .order_by(ToolInstance.created_at.desc()) ) - instances = result.scalars().all() - - instances_data = [] - for i in instances: - tool_type = await session.get(ToolType, i.tool_type_id) - instances_data.append( - { - "id": str(i.id), - "name": i.name, - "display_name": i.display_name, - "tool_type_id": str(i.tool_type_id), - "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], - "status": i.status, - "url": i.url, - "port": i.port, - "clone_mode": i.clone_mode, - "branch": i.branch, - "ssh_key_ids": i.ssh_key_ids or [], - "created_at": i.created_at.isoformat(), - } - ) - - return {"instances": instances_data} - - -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}", - summary="Get instance", - description="Get a specific instance with real-time status from Docker.", -) + instances = [] + for i in result.scalars().all(): + tt = await session.get(ToolType, i.tool_type_id) + instances.append({ + "id": str(i.id), "name": i.name, "display_name": i.display_name, + "tool_type_id": str(i.tool_type_id), "tool_type_name": tt.name if tt else "unknown", + "tool_type_interfaces": tt.interfaces if tt else [], + "status": i.status, "url": i.url, "port": i.port, + "config_profile_id": str(i.selected_profile_id) if i.selected_profile_id else None, + "created_at": i.created_at.isoformat(), + }) + return {"instances": instances} +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}") async def get_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get a specific instance with real-time status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with instance details and current status. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Get real-time status from Docker + """Get a specific instance with real-time Docker status.""" + from datetime import datetime + instance = await _get_instance(session, instance_id, repo_id) if instance.container_id: - docker_status = get_container_status(instance.container_id) + docker_status = container_svc.get_container_status(instance.container_id) if docker_status == "running" and instance.status != "running": instance.status = "running" await session.commit() @@ -1285,1544 +117,128 @@ async def get_instance( instance.status = "stopped" instance.last_stopped_at = datetime.now() await session.commit() - return { - "id": str(instance.id), - "name": instance.name, - "display_name": instance.display_name, - "tool_type_id": str(instance.tool_type_id), - "status": instance.status, - "container_id": instance.container_id, - "compose_path": instance.compose_path, - "url": instance.url, - "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, + "id": str(instance.id), "name": instance.name, "display_name": instance.display_name, + "tool_type_id": str(instance.tool_type_id), "status": instance.status, + "container_id": instance.container_id, "compose_path": instance.compose_path, + "url": instance.url, "port": instance.port, + "config_profile_id": str(instance.selected_profile_id) if instance.selected_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(), } - - -async def _prepare_manifest_instance( - session: AsyncSession, - instance: ToolInstance, - instance_dir: str, - repo_path: str, - env_vars: dict, - extra_volumes: list, - working_directory: str | None, -) -> tuple[str, str, dict, str]: - """Build image and generate compose from a manifest-based tool type. - - Returns: - Tuple of (image_tag, compose_content, resolved_manifest, home_dir) - """ - from src.models.tool_definition_manifest import ToolDefinitionManifest - - tool_type = await session.get(ToolType, instance.tool_type_id) - manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) - - if not manifest_def: - raise RuntimeError(f"Manifest not found for tool type {tool_type.id}") - - manifest = dict(manifest_def.manifest) - - # Resolve base if referenced - if manifest_def.base_definition_id: - base_def = await session.get( - ToolDefinitionManifest, manifest_def.base_definition_id - ) - if base_def: - base_manifest = dict(base_def.manifest) - manifest = resolve_base(deep_merge(base_manifest, manifest)) - else: - logger.warning( - "Base definition %s not found for manifest %s", - manifest_def.base_definition_id, - manifest_def.id, - ) - - manifest = merge_with_config(manifest) - - # Resolve extra env and volumes from merge_with_config - extra_env = manifest.pop("_extra_env", {}) - extra_cfg_volumes = manifest.pop("_extra_volumes", []) - env_vars.update(extra_env) - extra_volumes.extend(extra_cfg_volumes) - - # Compute image tag - image_tag = compute_image_tag(tool_type.name, manifest) - - # Check if image already exists - check = subprocess.run( - ["docker", "images", "-q", image_tag], - capture_output=True, - text=True, - ) - image_exists = check.returncode == 0 and check.stdout.strip() - - if not image_exists: - # Compile and build - dockerfile = compile_dockerfile(manifest) - entrypoint = compile_entrypoint(manifest) - - logger.debug( - "Compiled Dockerfile for instance %s (%d chars)", - instance.id, - len(dockerfile), - ) - - build_ctx = { - "Dockerfile": dockerfile, - ".headquarter/entrypoint.sh": entrypoint, - } - - returncode, stdout, stderr = await asyncio.to_thread( - build_image, - instance_dir=instance_dir, - dockerfile=dockerfile, - tag=image_tag, - build_context=build_ctx, - ) - - if returncode != 0: - raise RuntimeError(f"Docker build failed: {stderr}") - - logger.info("Built image %s for instance %s", image_tag, instance.id) - else: - logger.info("Reusing existing image %s for instance %s", image_tag, instance.id) - - # Prepare SSH path for mount resolution - ssh_path = "" - if instance.clone_mode == "clone": - ssh_path = os.path.join(instance_dir, ".ssh") - - # Resolve git mount variables from config profile - git_mount_vars = {} - if instance.selected_config_profile_id: - resolved_profile = await resolve_profile( - session, instance.selected_config_profile_id - ) - for gm in resolved_profile.git_mounts or []: - ref = gm.get("git_mount_ref", "default") - # The actual resolution happens in _resolve_git_mounts; we store placeholder - git_mount_vars[f"GIT_MOUNT_{ref}"] = "" - - variables = { - "IMAGE_TAG": image_tag, - "INSTANCE_NAME": instance.name.lower(), - "INSTANCE_DIR": instance_dir, - "REPO_PATH": repo_path, - "SSH_PATH": ssh_path, - "TOOL_PORT": instance.port or 0, - "EXTRA_ENV": env_vars, - "EXTRA_VOLUMES": extra_volumes, - **git_mount_vars, - } - - compose_content = compile_compose(manifest, variables) - - logger.debug( - "_prepare_manifest_instance for %s: repo_path=%s compose_volumes=%s", - instance.id, - repo_path or "", - manifest.get("mounts", []), - ) - logger.debug( - "Generated compose for %s:\n%s", - instance.id, - compose_content, - ) - - # Cache - instance.image_tag = image_tag - instance.manifest_compiled_at = datetime.now() - - home_dir = get_manifest_home_dir(manifest) - return image_tag, compose_content, manifest, home_dir - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/start", - summary="Start instance", - description="Start a tool instance using Docker Compose.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start") 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), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Start a tool instance. - - Args: - 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. - - Returns: - Dictionary with status and URL of the running instance. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - 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() - - # Store SSH key selection if provided - if data and data.ssh_key_ids is not None: - instance.ssh_key_ids = data.ssh_key_ids or None - 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" - ) - - instance.status = "building" - await session.commit() - logger.info("Starting instance %s (name=%s)", instance.id, instance.name) - - # Runtime overrides populated by config profiles - env_vars = {} - config_files = {} - port_override = None - start_command = None - working_directory = None - extra_volumes = [] - - # Fetch tool type early to determine home directory and container user - tool_type = await session.get(ToolType, instance.tool_type_id) - home_dir = "/root" - container_uid = 0 - container_gid = 0 - if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: - from src.models.tool_definition_manifest import ToolDefinitionManifest - - manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id) - if manifest_def: - manifest = dict(manifest_def.manifest) - # Merge with base definition if referenced (user config is often in base) - if manifest_def.base_definition_id: - base_def = await session.get( - ToolDefinitionManifest, manifest_def.base_definition_id - ) - if base_def: - manifest = resolve_base( - deep_merge(dict(base_def.manifest), manifest) - ) - home_dir = get_manifest_home_dir(manifest) - user_cfg = manifest.get("user") - if user_cfg: - container_uid = user_cfg.get("uid", 0) - container_gid = user_cfg.get("gid", 0) - logger.debug( - "Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s", - instance.id, - container_uid, - container_gid, - home_dir, - ) - - # 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, home_dir) - ) - # 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) - # Git repository mounts are resolved and added - git_mount_volumes = await _resolve_git_mounts( - session, resolved, instance_dir, working_directory, home_dir - ) - extra_volumes.extend(git_mount_volumes) - # 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.debug( - "Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)", - resolved.profile_name, - instance.id, - len(profile_env), - len(profile_files), - len(profile_mounts), - len(git_mount_volumes), - ) - 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.debug("No config profile selected for instance %s", instance.id) - - # Write env file and config files - env_file_path = None - - if env_vars: - env_file_path = write_env_file(instance_dir, env_vars) - logger.debug("Wrote env file for instance %s: %s", instance.id, env_file_path) - - if config_files: - write_config_files(instance_dir, config_files) - logger.debug( - "Wrote %d config files for instance %s", len(config_files), instance.id - ) - - # Mount selected SSH keys into container home dir - if instance.ssh_key_ids: - from src.services.ssh_keys import write_ssh_config, _sanitize_filename - - # Collect all valid keys first - ssh_keys_to_mount = [] - for key_id in instance.ssh_key_ids: - ssh_key = await session.get(SSHKey, uuid.UUID(key_id)) - if ssh_key and ssh_key.user_id == user_id: - ssh_keys_to_mount.append(ssh_key) - else: - logger.warning( - "SSH key %s not found or not authorized for user %s", - key_id, - user_id, - ) - - if ssh_keys_to_mount: - # Use a single shared .ssh directory so all keys are visible - ssh_dir = os.path.join(instance_dir, "mounts", "ssh", ".ssh") - os.makedirs(ssh_dir, exist_ok=True) - - key_filenames = [] - for ssh_key in ssh_keys_to_mount: - # Use sanitized key name as filename prefix to avoid collisions - key_name = _sanitize_filename(ssh_key.name) - # If multiple keys have the same name, append a short hash - base_filename = f"id_ed25519_{key_name}" - filename = base_filename - counter = 1 - while filename in key_filenames: - filename = f"{base_filename}_{counter}" - counter += 1 - key_filenames.append(filename) - - try: - prepare_ssh_key_files( - instance_dir, - ssh_key, - subdir="mounts/ssh/.ssh", - uid=container_uid, - gid=container_gid, - key_filename=filename, - write_config=False, - ) - logger.debug( - "Prepared SSH key %s as %s for instance %s", - ssh_key.name, - filename, - instance.id, - ) - except Exception as exc: - logger.error( - "Failed to prepare SSH key %s for instance %s: %s", - ssh_key.id, - instance.id, - exc, - ) - - # Write combined SSH config with all keys - try: - write_ssh_config( - ssh_dir, - key_filenames, - uid=container_uid, - gid=container_gid, - ) - except Exception as exc: - logger.error( - "Failed to write SSH config for instance %s: %s", - instance.id, - exc, - ) - - # Mount the single .ssh directory into container home - ssh_target = os.path.join(home_dir, ".ssh") - extra_volumes.append( - { - "source": ssh_dir, - "target": ssh_target, - "type": "bind", - } - ) - logger.debug( - "Mounted %d SSH key(s) for instance %s to %s", - len(ssh_keys_to_mount), - instance.id, - ssh_target, - ) - - # ── MANIFEST-BASED FLOW ────────────────────────────────────── - resolved_manifest = None - - if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id: - logger.info("Using manifest-based startup for instance %s", instance.id) - - # Determine repo path (workspace takes precedence) - repo_path = "" - if instance.workspace_id: - from src.models.workspace import Workspace as WorkspaceModel - - workspace = await session.get(WorkspaceModel, instance.workspace_id) - if workspace: - repo_path = workspace.path - else: - repo = await session.get(GitRepository, instance.repository_id) - repo_path = repo.path if repo else "" - if instance.clone_mode == "clone": - repo_path = os.path.join(instance_dir, "repo-clone") - - try: - ( - image_tag, - compose_content, - resolved_manifest, - _home_dir, - ) = await _prepare_manifest_instance( - session=session, - instance=instance, - instance_dir=instance_dir, - repo_path=repo_path, - env_vars=env_vars, - extra_volumes=extra_volumes, - working_directory=working_directory, - ) - write_compose_file(instance_dir, compose_content) - logger.debug( - "Generated manifest-based compose for instance %s", instance.id - ) - except Exception as exc: - logger.exception( - "Manifest compilation failed for instance %s: %s", instance.id, exc - ) - instance.status = "error" - await session.commit() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Manifest compilation failed: {exc}", - ) - else: - # ── LEGACY FLOW ────────────────────────────────────────── - # Mount SSH key for clone-mode instances (skip for workspace-based) - if instance.clone_mode == "clone" and not instance.workspace_id: - repo = await session.get(GitRepository, instance.repository_id) - if repo and repo.ssh_key_id: - ssh_key = await session.get(SSHKey, repo.ssh_key_id) - if ssh_key: - try: - ssh_dir = prepare_ssh_key_files( - instance_dir, ssh_key, uid=0, gid=0 - ) - extra_volumes.append( - { - "source": ssh_dir, - "target": "/root/.ssh", - "type": "bind", - } - ) - logger.debug( - "Mounted SSH key for clone-mode instance %s", instance.id - ) - except Exception as exc: - logger.error( - "Failed to prepare SSH key for instance %s: %s", - instance.id, - exc, - ) - - # Modify compose file if needed (port override, start command, working dir, volumes) - if port_override or start_command or working_directory or extra_volumes: - _modify_compose_file( - instance.compose_path, - port_override, - start_command, - working_directory, - extra_volumes, - home_dir, - ) - logger.debug("Modified compose file for instance %s", instance.id) - - # Sanitize compose file to remove invalid port mappings from old instances - _sanitize_compose_file(instance.compose_path) - - # Auto-fix bind address for known web tools that default to localhost - if tool_type and tool_type.interface_type == "web": - _ensure_web_bind_address( - instance.compose_path, tool_type.name, tool_type.default_port - ) - - # Ensure predictable container name for tunnel connectivity - _ensure_container_name_in_compose(instance.compose_path, instance.name) - _ensure_backend_network_in_compose(instance.compose_path) - - # Execute docker compose up with env file - logger.debug( - "Running docker compose up for instance %s (compose_path=%s)", - instance.id, - instance.compose_path, - ) - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "up", env_file=env_file_path - ) - logger.debug( - "Docker compose up completed for instance %s: returncode=%d, stdout=%s, stderr=%s", - instance.id, - returncode, - stdout[:200] if stdout else "", - stderr[:500] if stderr else "", - ) - - if returncode != 0: - instance.status = "error" - await session.commit() - logger.error("Failed to start instance %s: %s", instance.id, stderr) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"failed to start instance: {stderr}", - ) - - # Get container ID and name (use predictable name from compose) - expected_container_name = instance.name.lower() - container_id = get_container_id(expected_container_name) - if container_id: - instance.container_id = container_id - logger.debug("Container ID for instance %s: %s", instance.id, container_id) - - instance.container_name = expected_container_name - logger.debug( - "Container name for instance %s: %s", instance.id, expected_container_name - ) - - # Verify container reached running state - if instance.container_id: - instance.status = "starting" - instance.last_started_at = datetime.now() - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.started", - created_by=user_id, - status="starting", - message="Container starting...", - ) - logger.debug("Instance %s: verifying container startup...", instance.id) - - startup_result = wait_for_container_running( - instance.container_id, timeout=30, interval=2.0 - ) - - if not startup_result["success"]: - # Container failed to start - error_msg = f"Container failed to start: status={startup_result['status']}" - if startup_result["exit_code"] is not None: - error_msg += f", exit_code={startup_result['exit_code']}" - - # Get logs for debugging - logs = get_container_logs(instance.container_id, tail=50) - - instance.status = "error" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.error", - created_by=user_id, - status="error", - message=error_msg, - metadata={ - "exit_code": startup_result["exit_code"], - "error_type": "container", - }, - ) - logger.error( - "Instance %s container startup failed after %.1fs: %s\nLogs:\n%s", - instance.id, - startup_result["waited_seconds"], - error_msg, - logs, - ) - return { - "status": "error", - "error": error_msg, - "logs": logs, - } - - logger.debug( - "Instance %s container started successfully after %.1fs", - instance.id, - startup_result["waited_seconds"], - ) - - # Apply mount permission fixes for manifest-based instances - if resolved_manifest and instance.container_id: - mounts = resolved_manifest.get("mounts", []) - if mounts: - logger.debug( - "Applying permission fixes for instance %s (%d mounts)", - instance.id, - len(mounts), - ) - permission_results = apply_mount_permissions( - instance.container_id, - mounts, - ) - for result in permission_results: - if not result["success"]: - logger.warning( - "Permission fix failed for mount %s on instance %s: %s", - result["mount_name"], - instance.id, - result["error"], - ) - - # Fix SSH key ownership/permissions inside the container - if instance.ssh_key_ids and instance.container_id: - container_user = ( - "root" - if home_dir == "/root" - else home_dir[6:] - if home_dir.startswith("/home/") - else "root" - ) - ssh_target = os.path.join(home_dir, ".ssh") - logger.debug( - "Applying SSH permissions for user %s on %s in instance %s", - container_user, - ssh_target, - instance.id, - ) - ssh_perm_result = apply_ssh_permissions( - instance.container_id, - ssh_target, - container_user, - ) - if not ssh_perm_result["success"]: - logger.warning( - "SSH permission fix failed for instance %s: %s", - instance.id, - ssh_perm_result["error"], - ) - - # Execute readiness probe if configured - tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and instance.container_id: - # Determine probe command - probe_command = None - probe_timeout = 30 - probe_interval = 2 - - if tool_type.readiness_probe: - probe_config = tool_type.readiness_probe - probe_command = probe_config.get("command", "") - probe_timeout = probe_config.get("timeout", 30) - probe_interval = probe_config.get("interval", 2) - elif tool_type.interface_type == "web": - # Default probe for web tools - probe_command = f"curl -f http://localhost:{tool_type.default_port or 8080}" - probe_timeout = 30 - probe_interval = 2 - - if probe_command: - instance.status = "probing" - await session.commit() - logger.debug( - "Executing readiness probe for instance %s: command='%s', timeout=%d, interval=%d", - instance.id, - probe_command, - probe_timeout, - probe_interval, - ) - - success, probe_logs = await execute_probe( - container_id=instance.container_id, - command=probe_command, - timeout=probe_timeout, - interval=probe_interval, - ) - - # Store probe result - instance.probe_result = { - "success": success, - "command": probe_command, - "logs": probe_logs, - "timestamp": datetime.now().isoformat(), - } - - if not success: - instance.status = "unhealthy" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.health_changed", - created_by=user_id, - status="unhealthy", - message="Readiness probe failed", - metadata={"probe_output": "\n".join(probe_logs)}, - ) - logger.error( - "Readiness probe failed for instance %s after %ds: %s", - instance.id, - probe_timeout, - "\n".join(probe_logs), - ) - return { - "status": "unhealthy", - "error": f"Readiness probe failed after {probe_timeout}s", - "probe_logs": probe_logs, - } - - logger.info("Readiness probe succeeded for instance %s", instance.id) - - instance.status = "running" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.health_changed", - created_by=user_id, - status="running", - message="Container running", - metadata={"previous_status": "starting"}, - ) - logger.info("Instance %s is now running", instance.id) - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type: - logger.error("Tool type %s not found", instance.tool_type_id) - instance.status = "error" - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.error", - created_by=user_id, - status="error", - message=f"Tool type '{instance.tool_type_id}' not found", - ) - return { - "status": "error", - "error": f"Tool type '{instance.tool_type_id}' not found", - } - - logger.debug( - "Tool type for instance %s: name=%s, container_port=%s, interface_type=%s", - instance.id, - tool_type.name, - tool_type.default_port or 0, - tool_type.interface_type, - ) - - # Only create Cloudflare tunnel for web-enabled tools - if tool_type.interface_type == "web": - # Create temporary Cloudflare tunnel for public access - try: - logger.debug( - "Creating tunnel for instance %s (container_port=%d)", - instance.id, - tool_type.default_port or 0, - ) - tunnel_info = start_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - ) - instance.tunnel_id = tunnel_info["container_name"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - await session.commit() - logger.debug( - "Created tunnel for instance %s: container=%s, url=%s", - instance.id, - tunnel_info["container_name"], - tunnel_info["url"], - ) - except Exception as exc: - import traceback - - error_msg = str(exc) - error_trace = traceback.format_exc() - logger.error( - "Failed to create tunnel for instance %s: %s\nTraceback:\n%s", - instance.id, - error_msg, - error_trace, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {error_msg}", - } - else: - # Terminal-only tool - no tunnel needed - logger.info( - "Instance %s is terminal-only (no web interface), skipping tunnel creation", - instance.id, - ) - instance.url = None - instance.public_url = None - await session.commit() - - return {"status": instance.status, "url": instance.url} - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop", - summary="Stop instance", - description="Stop a running tool instance.", -) + """Start a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + return await lifecycle.start_existing_instance(session, instance, user, project_id) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop") async def stop_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Stop a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to stop. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with the stopped status. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - except Exception as exc: - logger.warning( - "Failed to stop tunnel for instance %s: %s", instance.id, exc - ) - - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "stop") - - instance.status = "stopped" - instance.last_stopped_at = datetime.now() - instance.url = None - instance.public_url = None - instance.tunnel_id = None - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.stopped", - created_by=user_id, - status="stopped", - message="Instance stopped", - ) - + """Stop a running tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + await lifecycle.stop_existing_instance(session, instance) return {"status": instance.status} - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart", - summary="Restart instance", - description="Restart a tool instance.", -) +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart") async def restart_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Restart a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to restart. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with status and URL of the restarted instance. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Stop old tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped old tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - 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.debug( - "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, - ) - - # Re-apply compose fixes in case they were updated since last start - _sanitize_compose_file(instance.compose_path) - tool_type = await session.get(ToolType, instance.tool_type_id) - if tool_type and tool_type.interface_type == "web": - _ensure_web_bind_address( - instance.compose_path, tool_type.name, tool_type.default_port - ) - _ensure_container_name_in_compose(instance.compose_path, instance.name) - _ensure_backend_network_in_compose(instance.compose_path) - - returncode, stdout, stderr = execute_compose_command( - instance.compose_path, "restart" - ) - - if returncode == 0: - instance.status = "running" - instance.last_started_at = datetime.now() - - # Get tool type for default port - tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type or not tool_type.default_port: - logger.error( - "Tool type %s has no default_port configured. Cannot create tunnel.", - instance.tool_type_id, - ) - instance.status = "error" - await session.commit() - return { - "status": "error", - "error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured", - } - - # Only create tunnel for web-enabled tools - if tool_type.interface_type == "web": - # Create new tunnel - try: - tunnel_info = start_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - ) - instance.tunnel_id = tunnel_info["container_name"] - instance.public_url = tunnel_info["url"] - instance.url = tunnel_info["url"] - logger.debug( - "Created new tunnel for instance %s: %s", - instance.id, - tunnel_info["url"], - ) - except Exception as exc: - logger.warning( - "Failed to create tunnel for instance %s: %s", - instance.id, - exc, - ) - instance.status = "error" - instance.url = None - await session.commit() - return { - "status": "error", - "error": f"Failed to create tunnel: {exc}", - } - else: - # Terminal-only tool - instance.url = None - instance.public_url = None - - await session.commit() - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.restarted", - created_by=user_id, - status="running", - message="Instance restarted", - ) - return {"status": instance.status, "url": instance.url} - - instance.status = "error" - await session.commit() - return {"status": instance.status} - - -@router.delete( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}", - summary="Delete instance", - description="Delete a tool instance and remove its Docker containers and files.", -) + """Restart a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + return await lifecycle.restart_existing_instance(session, instance, user, project_id) +@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}") async def delete_instance( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - force: bool = False, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> None: - """Delete a tool instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - None with 204 status code. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Check dirty state for clone-mode instances - if instance.clone_mode == "clone" and not force: - instance_dir = ( - os.path.dirname(instance.compose_path) if instance.compose_path else None - ) - if instance_dir: - clone_path = os.path.join(instance_dir, "repo-clone") - if os.path.exists(clone_path): - is_dirty, changed_files = check_dirty_state(clone_path) - if is_dirty: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "message": "Repository has uncommitted changes", - "changed_files": changed_files, - "force_required": True, - }, - ) - - # Stop Cloudflare tunnel if exists - if instance.tunnel_id: - try: - stop_tunnel(instance.name) - logger.debug( - "Stopped tunnel for instance %s (container=%s)", - instance.id, - instance.tunnel_id, - ) - except Exception as exc: - logger.warning( - "Failed to stop tunnel for instance %s: %s", instance.id, exc - ) - - # Stop and remove container - if instance.compose_path and os.path.exists(instance.compose_path): - execute_compose_command(instance.compose_path, "down") - - # Remove instance directory (includes clone and SSH keys) - if instance.compose_path: - instance_dir = os.path.dirname(instance.compose_path) - if os.path.exists(instance_dir): - import shutil - - shutil.rmtree(instance_dir) - - await publish_lifecycle_event( - event_bus=_event_bus, - session=session, - instance=instance, - event_type="instance.deleted", - created_by=user_id, - status="deleted", - message="Instance deleted", - ) - await session.delete(instance) - await session.commit() - - -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs", - summary="Get instance logs", - description="Get container logs for a tool instance.", -) + """Delete a tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + await lifecycle.delete_existing_instance(session, instance) +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs") async def get_instance_logs( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, tail: int = 100, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get container logs for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - tail: Number of log lines to return (default: 100). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing the container logs. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - + """Get container logs for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) if not instance.container_id: return {"logs": "No container running"} - - logs = get_container_logs(instance.container_id, tail) - return {"logs": logs} - - -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel", - summary="Recreate tunnel", - description="Recreate the temporary Cloudflare tunnel for a running instance.", -) + return {"logs": container_svc.get_container_logs(instance.container_id, tail)} +@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/recreate-tunnel") async def recreate_tunnel_endpoint( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Recreate the temporary tunnel for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with new URL and status. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - + """Recreate the temporary tunnel for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) if instance.status != "running": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="instance must be running to recreate tunnel", - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="instance must be running") tool_type = await session.get(ToolType, instance.tool_type_id) - if not tool_type: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tool type not found for this instance", - ) - - expected_name = instance.name.lower() - logger.info( - "Recreate tunnel for instance %s (expected container name: %s, default_port: %s)", - instance.id, - expected_name, - tool_type.default_port, - ) - - # Find the tool container — try stored ID first, then fall back to name lookup - tool_container_id = instance.container_id - if tool_container_id: - logger.info("Using stored container_id: %s", tool_container_id) - else: - tool_container_id = get_container_id(expected_name) - if tool_container_id: - logger.info("Found container by name: %s", tool_container_id) - else: - logger.error("Container %s not found", expected_name) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Could not find running container for this instance", - ) - - # Ensure the tool container is on the backend network so the tunnel can reach it - network_name = get_backend_network_name() - on_network = is_container_on_network(tool_container_id, network_name) - logger.info( - "Container %s on network %s: %s", - tool_container_id, - network_name, - on_network, - ) - if not on_network: - logger.info( - "Connecting container %s to network %s", - tool_container_id, - network_name, - ) - connected = connect_container_to_network(tool_container_id, network_name) - logger.info("Network connect result: %s", connected) - - # Get the container's IP on the backend network - target_ip = get_container_ip_on_network(tool_container_id, network_name) - if target_ip: - target_url = f"http://{target_ip}:{tool_type.default_port or 0}" - logger.info( - "Tunnel target for instance %s: %s (IP %s on %s)", - instance.id, - target_url, - target_ip, - network_name, - ) - else: - target_url = f"http://{expected_name}:{tool_type.default_port or 0}" - logger.warning( - "Could not get container IP, falling back to name-based target: %s", - target_url, - ) + instance_port = tool_type.default_port if tool_type and tool_type.default_port else 8080 try: - tunnel_info = recreate_tunnel( - instance_name=instance.name, - container_port=tool_type.default_port or 0, - target_url=target_url, + tunnel_info = tunnel_svc.recreate_tunnel( + container_name=instance.container_name or instance.name, + port=instance_port, + old_pid=instance.tunnel_id, ) - logger.info( - "Tunnel recreated: container=%s, url=%s", - tunnel_info["container_name"], - tunnel_info["url"], - ) - - # Verify the tunnel can actually reach the origin - health = check_tunnel_health(tunnel_info["url"], timeout=10) - logger.info( - "Tunnel health check: status=%s, code=%s, error=%s", - health.get("tunnel_status"), - health.get("status_code"), - health.get("error"), - ) - - # Also probe from inside the API container directly to the target - probe = subprocess.run( - [ - "curl", - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "--max-time", - "5", - target_url, - ], - capture_output=True, - text=True, - ) - logger.info( - "Direct probe from API to %s: HTTP %s", target_url, probe.stdout.strip() - ) - - instance.tunnel_id = tunnel_info["container_name"] + instance.tunnel_id = tunnel_info["pid"] instance.public_url = tunnel_info["url"] instance.url = tunnel_info["url"] await session.commit() return {"status": "healthy", "url": instance.url} except Exception as exc: - logger.exception("Failed to recreate tunnel for instance %s", instance.id) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to recreate tunnel: {str(exc)}", - ) - - -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/health", - summary="Check instance health", - description="Check container and tunnel health for an instance.", -) + logger.exception("Failed to recreate tunnel") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to recreate tunnel: {exc}") +@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/health") async def check_instance_tunnel_health( project_id: uuid.UUID, repo_id: uuid.UUID, instance_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Check health for an instance (container + tunnel). - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with container_status, tunnel_status, probe_status, and overall healthy flag. - """ - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Check container status - container_info = {"status": "not_found", "exit_code": None, "health": None} - if instance.container_id: - container_info = get_container_status(instance.container_id) - - # Build response - response = { - "healthy": False, - "container_status": container_info["status"], - "container_health": container_info["health"], - "tunnel_status": "not_applicable", - "tunnel_status_code": None, - "probe_status": "not_applicable", - "last_probe_output": None, - "error": None, - } - - # Determine probe status - if instance.status == "probing": - response["probe_status"] = "pending" - elif instance.probe_result: - response["probe_status"] = ( - "success" if instance.probe_result.get("success") else "failed" - ) - response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", [])) - - # Check tunnel health if instance has a URL and is web-enabled - if instance.url and instance.status in ("running", "unhealthy"): - tunnel_health = check_tunnel_health(instance.url) - response["tunnel_status"] = tunnel_health["tunnel_status"] - response["tunnel_status_code"] = tunnel_health.get("status_code") - if tunnel_health.get("error"): - response["error"] = tunnel_health["error"] - - # Overall healthy: web tools need running container + healthy tunnel; - # terminal tools only need running container - container_healthy = container_info["status"] == "running" - if instance.url: - tunnel_healthy = response["tunnel_status"] == "healthy" - response["healthy"] = container_healthy and tunnel_healthy - else: - response["healthy"] = container_healthy - - # If container is not running, override error message - if not container_healthy: - response["error"] = f"Container is {container_info['status']}" - if container_info["exit_code"] is not None: - response["error"] += f" (exit code: {container_info['exit_code']})" - - return response - - -@router.get( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/events", - summary="Get instance events history", - description="Get lifecycle event history for a tool instance.", -) -async def get_instance_events( - project_id: uuid.UUID, - repo_id: uuid.UUID, - instance_id: uuid.UUID, - limit: int = 50, - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> list[dict]: - """Get lifecycle event history for an instance. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - limit: Maximum number of events to return (default: 50). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of event dictionaries. - """ - from sqlalchemy import select - from src.models.instance_event import InstanceEvent - - _user = await _get_user(session, user_id) - _project = await _get_owned_project(project_id, user_id, session) - - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - result = await session.execute( - select(InstanceEvent) - .where(InstanceEvent.instance_id == instance_id) - .order_by(InstanceEvent.created_at.desc()) - .limit(limit) - ) - rows = result.scalars().all() - - return [ - { - "id": str(row.id), - "event_type": row.event_type, - "status": row.status, - "message": row.message, - "metadata": row.event_metadata, - "created_at": row.created_at.isoformat() if row.created_at else None, - } - for row in rows - ] - - -@router.get( + """Check tunnel health for an instance.""" + instance = await _get_instance(session, instance_id, repo_id) + if not instance.url or instance.status != "running": + return {"healthy": False, "status_code": None, "error": "instance not running"} + return tunnel_svc.check_tunnel_health(instance.url) +@router.api_route( "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", -) -@router.post( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.put( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.delete( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.patch( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.head( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, -) -@router.options( - "/{project_id}/repositories/{repo_id}/instances/{instance_id}/proxy/{path:path}", - summary="Proxy to instance", - description="Proxy HTTP requests to a running tool instance.", - include_in_schema=False, + methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], ) async def proxy_to_instance( request: Request, @@ -2830,152 +246,39 @@ async def proxy_to_instance( repo_id: uuid.UUID, instance_id: uuid.UUID, path: str = "", - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> Response: - """Proxy requests to a running tool instance. - - Args: - request: The incoming HTTP request. - project_id: UUID of the project. - repo_id: UUID of the repository. - instance_id: UUID of the instance. - path: The path to proxy to the instance. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Response from the proxied instance. - """ - instance = await session.get(ToolInstance, instance_id) - if instance is None or instance.repository_id != repo_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="instance not found" - ) - - # Verify ownership - if instance.owner_id != user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="not authorized to access this instance", - ) - + """Proxy HTTP requests to a running tool instance.""" + instance = await _get_instance(session, instance_id, repo_id) + if instance.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not authorized") if instance.status != "running" or not instance.container_name: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="instance is not running", - ) + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="instance is not running") - # Build target URL target_url = f"http://{instance.container_name}:{instance.port}" if path: target_url += f"/{path}" + query = str(request.query_params) + if query: + target_url += f"?{query}" - # Get query string - query_string = str(request.query_params) - if query_string: - target_url += f"?{query_string}" - - # Forward headers (excluding host) headers = dict(request.headers) headers.pop("host", None) - headers.pop("cookie", None) # Don't forward session cookies + headers.pop("cookie", None) - # Forward the request try: async with httpx.AsyncClient() as client: body = await request.body() response = await client.request( - method=request.method, - url=target_url, - headers=headers, - content=body, - follow_redirects=False, - timeout=30.0, + method=request.method, url=target_url, headers=headers, + content=body, follow_redirects=False, timeout=30.0, ) except Exception as exc: logger.error("Proxy error: %s", exc) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"failed to reach instance: {exc}", - ) + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"failed to reach instance: {exc}") - # Build response - response_headers = dict(response.headers) - # Remove hop-by-hop headers - for header in ["content-encoding", "transfer-encoding", "connection"]: - response_headers.pop(header, None) - - return Response( - content=response.content, - status_code=response.status_code, - headers=response_headers, - ) - - -sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) - - -@sessions_router.get( - "/me/sessions", - summary="Get user sessions", - description="Get all active sessions (running instances) for the current user.", -) -async def get_user_sessions( - user_id: uuid.UUID = Depends(get_current_user_id), - session: AsyncSession = Depends(get_db_session), -) -> dict: - """Get all active sessions for the current user. - - Args: - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing list of active sessions with instance details. - """ - _user = await _get_user(session, user_id) - - result = await session.execute( - select(ToolInstance) - .where(ToolInstance.owner_id == user_id) - .where( - ToolInstance.status.in_( - ["running", "building", "pending", "stopped", "error"] - ) - ) - .order_by(ToolInstance.created_at.desc()) - ) - instances = result.scalars().all() - - sessions = [] - for instance in instances: - tool_type = await session.get(ToolType, instance.tool_type_id) - repo = await session.get(GitRepository, instance.repository_id) - project = await session.get(Project, instance.project_id) - - sessions.append( - { - "id": str(instance.id), - "display_name": instance.display_name, - "tool_type_name": tool_type.name if tool_type else "unknown", - "tool_icon": tool_type.name if tool_type else "code", - "tool_type_interfaces": [tool_type.interface_type] if tool_type else [], - "repository_name": repo.name if repo else "unknown", - "repository_id": str(instance.repository_id), - "project_name": project.name if project else "unknown", - "project_id": str(instance.project_id), - "status": instance.status, - "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, - } - ) - - return {"sessions": sessions} + resp_headers = dict(response.headers) + for h in ["content-encoding", "transfer-encoding", "connection"]: + resp_headers.pop(h, None) + return Response(content=response.content, status_code=response.status_code, headers=resp_headers) diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index d669c39..6cad489 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -1,19 +1,15 @@ import uuid from datetime import datetime +import yaml from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.api.tool_types_validation import ( - check_port_exposed, - validate_compose_yaml, - validate_required_variables, -) -from src.auth.dependencies import _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import get_current_user, get_db_session from src.models.tool_type import ToolType from src.models.user import User +from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest router = APIRouter(prefix="/tool-types", tags=["tool-types"]) @@ -29,237 +25,6 @@ async def _require_admin(user: User) -> None: pass -class ToolTypeCreate(BaseModel): - name: str - display_name: str - description: str | None = None - default_port: int = 0 - definition_type: str = "compose" - manifest_id: uuid.UUID | None = None - compose_template: str | None = None - dockerfile_template: str | None = None - build_context: dict | None = None - readiness_probe: dict | None = None - startup_command: str | None = None - required_variables: list[str] = [] - category: str = "other" - interface_type: str = "web" - requires_port: bool = True - - @field_validator("definition_type") - @classmethod - def validate_definition_type(cls, v: str) -> str: - if v not in ("compose", "dockerfile", "manifest"): - raise ValueError( - "definition_type must be 'compose', 'dockerfile', or 'manifest'" - ) - return v - - @field_validator("compose_template") - @classmethod - def validate_compose_template(cls, v: str | None, info) -> str | None: - data = info.data - if data.get("definition_type") != "compose": - return v - - if v is None or not v.strip(): - raise ValueError( - "compose_template is required when definition_type is 'compose'" - ) - - validate_compose_yaml(v) - return v - - @field_validator("dockerfile_template") - @classmethod - def validate_dockerfile_template(cls, v: str | None, info) -> str | None: - data = info.data - if data.get("definition_type") != "dockerfile": - return v - - if v is None or not v.strip(): - raise ValueError( - "dockerfile_template is required when definition_type is 'dockerfile'" - ) - - if not v.strip().startswith("FROM"): - raise ValueError("Dockerfile must start with a FROM instruction") - - return v - - @field_validator("interface_type") - @classmethod - def validate_interface_type(cls, v: str) -> str: - if v not in ("web", "terminal"): - raise ValueError("interface_type must be 'web' or 'terminal'") - return v - - @field_validator("default_port") - @classmethod - def validate_default_port(cls, v: int, info) -> int: - data = info.data - requires_port = data.get("requires_port", True) - if not requires_port: - return v - if v <= 0 or v > 65535: - raise ValueError("Port must be between 1 and 65535") - return v - - @field_validator("required_variables") - @classmethod - def validate_required_variables(cls, v: list[str], info) -> list[str]: - if not v: - return v - - data = info.data - if data.get("definition_type") != "compose": - return v - - template = data.get("compose_template") - if not template: - return v - - for var in v: - placeholder = f"{{{{{var}}}}}" - if placeholder not in template: - raise ValueError( - f"Required variable '{var}' not found in compose template" - ) - - return v - - @model_validator(mode="after") - def validate_templates(self) -> "ToolTypeCreate": - if self.definition_type == "manifest": - if self.manifest_id is None: - raise ValueError( - "manifest_id is required when definition_type is 'manifest'" - ) - return self - - if self.definition_type == "dockerfile" and ( - self.dockerfile_template is None or not self.dockerfile_template.strip() - ): - raise ValueError( - "dockerfile_template is required when definition_type is 'dockerfile'" - ) - if self.definition_type == "compose" and ( - self.compose_template is None or not self.compose_template.strip() - ): - raise ValueError( - "compose_template is required when definition_type is 'compose'" - ) - - # Validate that default_port is exposed in compose template (only if requires_port) - if ( - self.requires_port - and self.definition_type == "compose" - and self.compose_template - ): - try: - parsed = validate_compose_yaml(self.compose_template) - except ValueError: - return self - - if not check_port_exposed(parsed, self.default_port): - raise ValueError( - f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section." - ) - - return self - - -class ToolTypeUpdate(BaseModel): - display_name: str | None = None - description: str | None = None - default_port: int | None = None - definition_type: str | None = None - manifest_id: uuid.UUID | None = None - compose_template: str | None = None - dockerfile_template: str | None = None - build_context: dict | None = None - readiness_probe: dict | None = None - startup_command: str | None = None - required_variables: list[str] | None = None - category: str | None = None - interface_type: str | None = None - requires_port: bool | None = None - - @field_validator("definition_type") - @classmethod - def validate_definition_type(cls, v: str | None) -> str | None: - if v is None: - return v - if v not in ("compose", "dockerfile", "manifest"): - raise ValueError( - "definition_type must be 'compose', 'dockerfile', or 'manifest'" - ) - return v - - @field_validator("interface_type") - @classmethod - def validate_interface_type(cls, v: str | None) -> str | None: - if v is None: - return v - if v not in ("web", "terminal"): - raise ValueError("interface_type must be 'web' or 'terminal'") - return v - - @field_validator("compose_template") - @classmethod - def validate_compose_template(cls, v: str | None, info) -> str | None: - if v is None: - return v - - data = info.data - definition_type = data.get("definition_type") - if definition_type and definition_type != "compose": - return v - - validate_compose_yaml(v) - return v - - @field_validator("dockerfile_template") - @classmethod - def validate_dockerfile_template(cls, v: str | None, info) -> str | None: - if v is None: - return v - - data = info.data - definition_type = data.get("definition_type") - if definition_type and definition_type != "dockerfile": - return v - - if not v.strip().startswith("FROM"): - raise ValueError("Dockerfile must start with a FROM instruction") - - return v - - -class ToolTypeResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - display_name: str - description: str | None - category: str - interface_type: str - requires_port: bool - default_port: int - definition_type: str - manifest_id: uuid.UUID | None - compose_template: str | None - dockerfile_template: str | None - build_context: dict | None - readiness_probe: dict | None - startup_command: str | None - required_variables: list[str] - created_by_id: uuid.UUID | None - created_at: datetime - updated_at: datetime - - @router.post( "", response_model=ToolTypeResponse, @@ -269,7 +34,7 @@ class ToolTypeResponse(BaseModel): ) async def create_tool_type( data: ToolTypeCreate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> ToolType: """Create a new tool type. @@ -282,33 +47,27 @@ async def create_tool_type( Returns: The newly created tool type. """ - user = await _get_user(session, user_id) await _require_admin(user) - + # Check for duplicate name existing = await session.scalar(select(ToolType).where(ToolType.name == data.name)) if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="tool type with this name already exists", - ) - + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists") + tool_type = ToolType( name=data.name, display_name=data.display_name, description=data.description, default_port=data.default_port, definition_type=data.definition_type, - manifest_id=data.manifest_id, compose_template=data.compose_template, dockerfile_template=data.dockerfile_template, build_context=data.build_context, readiness_probe=data.readiness_probe, - startup_command=data.startup_command, required_variables=data.required_variables, category=data.category, - interface_type=data.interface_type, - requires_port=data.requires_port, + interfaces=data.interfaces, + is_builtin=False, created_by_id=user.id, ) session.add(tool_type) @@ -324,7 +83,7 @@ async def create_tool_type( description="List all available tool types including built-in and custom ones.", ) async def list_tool_types( - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> list[ToolType]: """List all tool types. @@ -336,7 +95,6 @@ async def list_tool_types( Returns: List of all tool types ordered by name. """ - await _get_user(session, user_id) result = await session.execute(select(ToolType).order_by(ToolType.name)) return list(result.scalars().all()) @@ -349,7 +107,7 @@ async def list_tool_types( ) async def get_tool_type( tool_type_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> ToolType: """Get a specific tool type by ID. @@ -362,12 +120,9 @@ async def get_tool_type( Returns: The requested tool type. """ - await _get_user(session, user_id) 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" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") return tool_type @@ -380,7 +135,7 @@ async def get_tool_type( async def update_tool_type( tool_type_id: uuid.UUID, data: ToolTypeUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> ToolType: """Update a tool type. @@ -394,79 +149,88 @@ async def update_tool_type( Returns: The updated tool type. """ - user = await _get_user(session, user_id) await _require_admin(user) - + 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" - ) - - # Built-in tool types can now be modified - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + + if tool_type.is_builtin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot modify built-in tool types") + update_data = data.model_dump(exclude_unset=True) - + # Validate port if being updated - requires_port = update_data.get("requires_port", tool_type.requires_port) - if "default_port" in update_data and requires_port: + if "default_port" in update_data: new_port = update_data["default_port"] if new_port <= 0 or new_port > 65535: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Port must be between 1 and 65535", + detail="Port must be between 1 and 65535" ) - + # Only validate port exposure for compose definitions definition_type = update_data.get("definition_type", tool_type.definition_type) if definition_type == "compose": template = update_data.get("compose_template", tool_type.compose_template) if template: try: - parsed = validate_compose_yaml(template) - if not check_port_exposed(parsed, new_port): + parsed = yaml.safe_load(template) + except yaml.YAMLError: + parsed = None + + if parsed and isinstance(parsed, dict) and "services" in parsed: + port_str = str(new_port) + port_exposed = False + for service_config in parsed["services"].values(): + if isinstance(service_config, dict) and "ports" in service_config: + for port_mapping in service_config["ports"]: + if isinstance(port_mapping, str) and port_str in port_mapping: + port_exposed = True + break + elif isinstance(port_mapping, int) and port_mapping == new_port: + port_exposed = True + break + if port_exposed: + break + + if not port_exposed: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Port {new_port} is not exposed in the compose template", + detail=f"Port {new_port} is not exposed in the compose template" ) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) - ) - + # Validate required variables for compose definitions definition_type = update_data.get("definition_type", tool_type.definition_type) if definition_type == "compose": if "required_variables" in update_data and "compose_template" in update_data: - validate_required_variables( - update_data["compose_template"], update_data["required_variables"] - ) + template = update_data["compose_template"] + for var in update_data["required_variables"]: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Required variable '{var}' not found in compose template" + ) elif "required_variables" in update_data: template = tool_type.compose_template if template: - validate_required_variables(template, update_data["required_variables"]) - - # When switching to manifest, clear legacy templates - if definition_type == "manifest": - if "manifest_id" in update_data: - tool_type.manifest_id = update_data["manifest_id"] - tool_type.compose_template = None - tool_type.dockerfile_template = None - + for var in update_data["required_variables"]: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Required variable '{var}' not found in compose template" + ) + for field, value in update_data.items(): setattr(tool_type, field, value) - + await session.commit() await session.refresh(tool_type) return tool_type -class ToolTypeValidateRequest(BaseModel): - definition_type: str - compose_template: str | None = None - dockerfile_template: str | None = None - - @router.post( "/validate", summary="Validate tool type template", @@ -474,7 +238,7 @@ class ToolTypeValidateRequest(BaseModel): ) async def validate_tool_type_template( data: ToolTypeValidateRequest, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> dict: """Validate a tool type template syntax. @@ -487,7 +251,6 @@ async def validate_tool_type_template( Returns: Validation result with success status and any errors. """ - await _get_user(session, user_id) errors = [] @@ -496,9 +259,15 @@ async def validate_tool_type_template( errors.append("Compose template is required") else: try: - validate_compose_yaml(data.compose_template) - except ValueError as e: - errors.append(str(e)) + parsed = yaml.safe_load(data.compose_template) + if not isinstance(parsed, dict): + errors.append("Compose template must be a YAML mapping") + elif "services" not in parsed: + errors.append("Compose template must contain 'services' key") + elif not parsed["services"]: + errors.append("Compose template must define at least one service") + except yaml.YAMLError as e: + errors.append(f"Invalid YAML: {e}") elif data.definition_type == "dockerfile": if not data.dockerfile_template: @@ -506,11 +275,8 @@ async def validate_tool_type_template( elif not data.dockerfile_template.strip().startswith("FROM"): errors.append("Dockerfile must start with a FROM instruction") - elif data.definition_type == "manifest": - pass # Manifest validation is handled separately - else: - errors.append("definition_type must be 'compose', 'dockerfile', or 'manifest'") + errors.append("definition_type must be 'compose' or 'dockerfile'") return { "valid": len(errors) == 0, @@ -525,7 +291,7 @@ async def validate_tool_type_template( ) async def validate_tool_type( tool_type_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> dict: """Validate a tool type's template syntax. @@ -538,34 +304,33 @@ async def validate_tool_type( Returns: Validation result with success status and any errors. """ - await _get_user(session, user_id) 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" - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + errors = [] - + if tool_type.definition_type == "compose": if not tool_type.compose_template: errors.append("Compose template is empty") else: try: - validate_compose_yaml(tool_type.compose_template) - except ValueError as e: - errors.append(str(e)) - + parsed = yaml.safe_load(tool_type.compose_template) + if not isinstance(parsed, dict): + errors.append("Compose template must be a YAML mapping") + elif "services" not in parsed: + errors.append("Compose template must contain 'services' key") + elif not parsed["services"]: + errors.append("Compose template must define at least one service") + except yaml.YAMLError as e: + errors.append(f"Invalid YAML: {e}") + elif tool_type.definition_type == "dockerfile": if not tool_type.dockerfile_template: errors.append("Dockerfile template is empty") elif not tool_type.dockerfile_template.strip().startswith("FROM"): errors.append("Dockerfile must start with a FROM instruction") - - elif tool_type.definition_type == "manifest": - if not tool_type.manifest_id: - errors.append("Manifest reference is missing") - + return { "valid": len(errors) == 0, "errors": errors, @@ -580,7 +345,7 @@ async def validate_tool_type( ) async def delete_tool_type( tool_type_id: uuid.UUID, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> None: """Delete a tool type. @@ -593,16 +358,14 @@ async def delete_tool_type( Returns: None with 204 status code. """ - user = await _get_user(session, user_id) await _require_admin(user) - + 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" - ) - - # Built-in tool types can now be deleted - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") + + if tool_type.is_builtin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="cannot delete built-in tool types") + await session.delete(tool_type) await session.commit() diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py index 99a8892..ec2c4af 100644 --- a/apps/api/src/api/user_config.py +++ b/apps/api/src/api/user_config.py @@ -1,22 +1,20 @@ import logging import uuid -from fastapi import APIRouter, Depends -from pydantic import BaseModel, ConfigDict +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import get_current_user, get_db_session +from src.models.user import User from src.models.user_config import UserConfig - -logger = logging.getLogger(__name__) +from src.schemas.user_config import UserConfigResponse, UserConfigUpdate router = APIRouter(prefix="/users/me", tags=["user-config"]) -async def _get_or_create_config( - session: AsyncSession, user_id: uuid.UUID -) -> UserConfig: + +async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig: """Get or create user config record. Args: @@ -26,40 +24,16 @@ async def _get_or_create_config( Returns: The user's config, creating a new one if it doesn't exist. """ - result = await session.execute( - select(UserConfig).where(UserConfig.user_id == user_id) - ) + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user.id)) config = result.scalar_one_or_none() if config is None: - config = UserConfig(user_id=user_id, config={}) + config = UserConfig(user_id=user.id, config={}) session.add(config) await session.commit() await session.refresh(config) return config -class UserConfigResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - default_editor: str | None = None - theme: str = "system" - git_user_name: str | None = None - git_user_email: str | None = None - last_session_id: str | None = None - notification_mute_categories: list[str] | None = None - notification_toast_level: str | None = None - - -class UserConfigUpdate(BaseModel): - default_editor: str | None = None - theme: str | None = None - git_user_name: str | None = None - git_user_email: str | None = None - last_session_id: str | None = None - notification_mute_categories: list[str] | None = None - notification_toast_level: str | None = None - - @router.get( "/config", response_model=UserConfigResponse, @@ -67,7 +41,7 @@ class UserConfigUpdate(BaseModel): description="Get the current user's configuration settings.", ) async def get_user_config( - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> UserConfigResponse: """Get the current user's configuration. @@ -79,8 +53,7 @@ async def get_user_config( Returns: The user's configuration settings. """ - _user = await _get_user(session, user_id) - config = await _get_or_create_config(session, user_id) + config = await _get_or_create_config(session, user.id) return UserConfigResponse.model_validate(config.config) @@ -92,7 +65,7 @@ async def get_user_config( ) async def update_user_config( data: UserConfigUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> UserConfigResponse: """Update the current user's configuration. @@ -105,16 +78,15 @@ async def update_user_config( Returns: The updated user configuration. """ - _user = await _get_user(session, user_id) - config = await _get_or_create_config(session, user_id) + config = await _get_or_create_config(session, user.id) # Merge updates update_data = data.model_dump(exclude_unset=True) - logger.debug("Updating user config for user %s: %s", user_id, update_data) + logger.info("Updating user config for user %s: %s", user.id, update_data) # SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict config.config = {**config.config, **update_data} await session.commit() await session.refresh(config) - logger.debug("Updated config: %s", config.config) + logger.info("Updated config: %s", config.config) return UserConfigResponse.model_validate(config.config) diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index 5533b4b..1955f1b 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -2,11 +2,14 @@ import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile, status -from pydantic import BaseModel, ConfigDict +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.auth.dependencies import _get_user, get_current_user_id, get_db_session +from src.auth.dependencies import get_current_user, get_db_session +from src.models.tool_instance import ToolInstance from src.models.user import User +from src.schemas.tool_instance import SessionItemResponse, SessionListResponse +from src.schemas.user import UserProfileResponse, UserProfileUpdate router = APIRouter(prefix="/users", tags=["users"]) @@ -16,19 +19,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"} MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB -class UserProfileResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - email: str - name: str - avatar_url: str | None - - -class UserProfileUpdate(BaseModel): - name: str | None = None - email: str | None = None - @router.get( "/me", @@ -37,7 +27,7 @@ class UserProfileUpdate(BaseModel): description="Retrieve the profile of the currently authenticated user.", ) async def get_profile( - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> User: """Get the current user's profile. @@ -49,7 +39,7 @@ async def get_profile( Returns: The user's profile information. """ - return await _get_user(session, user_id) + return user @router.put( @@ -60,7 +50,7 @@ async def get_profile( ) async def update_profile( data: UserProfileUpdate, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> User: """Update the current user's profile. @@ -73,7 +63,6 @@ async def update_profile( Returns: The updated user profile. """ - user = await _get_user(session, user_id) if data.name is not None: if len(data.name.strip()) == 0: @@ -98,7 +87,7 @@ async def update_profile( ) async def upload_avatar( file: UploadFile, - user_id: uuid.UUID = Depends(get_current_user_id), + user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> User: """Upload a profile avatar image. @@ -111,7 +100,6 @@ async def upload_avatar( Returns: The updated user profile with new avatar URL. """ - user = await _get_user(session, user_id) if file.content_type not in ALLOWED_CONTENT_TYPES: raise HTTPException( @@ -146,3 +134,41 @@ async def upload_avatar( await session.commit() await session.refresh(user) return user + + +@router.get( + "/me/sessions", + response_model=SessionListResponse, + summary="Get current user sessions", + description="Retrieve all tool instances (sessions) for the authenticated user.", +) +async def get_user_sessions( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> SessionListResponse: + """Return all tool instances for the current user with related names.""" + result = await session.execute( + select(ToolInstance) + .where(ToolInstance.owner_id == user.id) + .order_by(ToolInstance.created_at.desc()) + ) + instances = result.scalars().all() + + sessions = [ + SessionItemResponse( + id=str(inst.id), + display_name=inst.display_name, + tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown", + tool_icon=inst.tool_type.icon if inst.tool_type else None, + tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [], + repository_name=inst.repository.name if inst.repository else "Unknown", + repository_id=str(inst.repository_id), + project_name=inst.project.name if inst.project else "Unknown", + project_id=str(inst.project_id), + status=inst.status, + url=inst.url, + ) + for inst in instances + ] + + return SessionListResponse(sessions=sessions) diff --git a/apps/api/src/auth/dependencies.py b/apps/api/src/auth/dependencies.py index 0e2a580..0b3ea9b 100644 --- a/apps/api/src/auth/dependencies.py +++ b/apps/api/src/auth/dependencies.py @@ -50,25 +50,17 @@ async def get_current_user( return user -async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: - """Fetch a user by ID or raise 401 if not found.""" - user = await session.get(User, user_id) - if user is None: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") - return user - - -async def _get_owned_project( +async def get_owned_project( project_id: uuid.UUID, - user_id: uuid.UUID, - session: AsyncSession, -) -> "Project": + user: User = Depends(get_current_user), + db_session: AsyncSession = Depends(get_db_session), +) -> Project: """Fetch a project and verify ownership. Args: - project_id: UUID of the project. - user_id: ID of the authenticated user. - session: Database session. + project_id: UUID of the project (injected from path parameter). + user: The currently authenticated user. + db_session: Database session. Returns: The project if found and owned by the user. @@ -76,11 +68,9 @@ async def _get_owned_project( Raises: HTTPException: 404 if project not found, 403 if user is not the owner. """ - from src.models.project import Project - - project = await session.get(Project, project_id) + project = await db_session.get(Project, project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found") - if project.owner_id != user_id: + if project.owner_id != user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not project owner") return project diff --git a/apps/api/src/logging_config.py b/apps/api/src/logging_config.py index 31ff758..df3a3f7 100644 --- a/apps/api/src/logging_config.py +++ b/apps/api/src/logging_config.py @@ -1,52 +1,15 @@ -"""Structured JSON logging configuration.""" - -import json import logging import sys import time import traceback -from collections.abc import Callable +from typing import Callable from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware -from src.services.correlation import get_correlation_id - logger = logging.getLogger(__name__) -class CorrelationIdFilter(logging.Filter): - """Inject correlation_id into every log record from context var.""" - - def filter(self, record: logging.LogRecord) -> bool: - record.correlation_id = get_correlation_id() # type: ignore[attr-defined] - return True - - -class JSONFormatter(logging.Formatter): - """Emit log records as single-line JSON.""" - - def format(self, record: logging.LogRecord) -> str: - log_obj: dict = { - "timestamp": self.formatTime(record), - "level": record.levelname, - "logger": record.name, - "message": record.getMessage(), - "correlation_id": getattr(record, "correlation_id", None), - } - # Optional extra fields - for key in ("instance_id", "event_type"): - value = getattr(record, key, None) - if value is not None: - log_obj[key] = value - if record.exc_info: - log_obj["exception"] = self.formatException(record.exc_info) - return json.dumps(log_obj, default=str) - - def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str: - return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created)) - - class RequestLoggingMiddleware(BaseHTTPMiddleware): """Log all HTTP requests with timing and status codes.""" @@ -54,6 +17,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): start_time = time.time() client_host = request.client.host if request.client else "unknown" + # Log the incoming request logger.info( "→ Request: %s %s (client: %s)", request.method, @@ -65,6 +29,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): response = await call_next(request) duration = time.time() - start_time + # Log the response logger.info( "← Response: %s %s → %d (%dms)", request.method, @@ -104,13 +69,15 @@ class ExceptionLoggingMiddleware(BaseHTTPMiddleware): def configure_logging(level: int = logging.INFO) -> None: - """Configure structured JSON logging for the application.""" - formatter = JSONFormatter() + """Configure structured logging for the application.""" + formatter = logging.Formatter( + fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) # Console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(formatter) - console_handler.addFilter(CorrelationIdFilter()) # Configure root logger root_logger = logging.getLogger() diff --git a/apps/api/src/main.py b/apps/api/src/main.py index d94f8a5..9e2cc4b 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -1,3 +1,4 @@ +import json import logging import os @@ -6,40 +7,30 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles - from src.api.auth import router as auth_router from src.api.dashboard import router as dashboard_router -from src.api.events import router as events_router from src.api.git_repositories import router as git_repositories_router from src.api.health import router as health_router from src.api.projects import router as projects_router 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_definitions import router as tool_definitions_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 from src.api.tool_types import router as tool_types_router -from src.api.notifications import router as notifications_router from src.api.user_config import router as user_config_router from src.api.users import router as users_router -from src.api.workspace_files import router as workspace_files_router -from src.api.workspace_git import router as workspace_git_router -from src.api.workspace_instances import router as workspace_instances_router -from src.api.workspaces import all_workspaces_router, router as workspaces_router from src.config import Settings -from src.models.notification import Notification # noqa: F401 – Alembic model discovery -from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery -from src.database import init_database +from src.database import SessionLocal, init_database from src.logging_config import ( ExceptionLoggingMiddleware, RequestLoggingMiddleware, configure_logging, ) -from src.services.correlation import CorrelationIdMiddleware -from src.services.event_bus import InstanceEventBus -from src.services.health_monitor import HealthMonitor +from src.seeds.builtin_tool_types import seed_builtin_tool_types # Configure logging early log_level = os.getenv("LOG_LEVEL", "INFO").upper() @@ -64,7 +55,6 @@ app.add_middleware( allow_headers=["*"], ) -app.add_middleware(CorrelationIdMiddleware) app.add_middleware(RequestLoggingMiddleware) app.add_middleware(ExceptionLoggingMiddleware) @@ -77,9 +67,7 @@ def _sanitize_validation_errors(errors): "type": error.get("type"), "loc": error.get("loc"), "msg": error.get("msg"), - "input": str(error.get("input")) - if error.get("input") is not None - else None, + "input": str(error.get("input")) if error.get("input") is not None else None, } # Convert ctx to safe format ctx = error.get("ctx") @@ -114,11 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) -# Global services -_event_bus = InstanceEventBus() -_health_monitor = HealthMonitor(_event_bus) - - @app.on_event("startup") async def on_startup(): logger.info("Starting up Headquarter API...") @@ -128,24 +111,12 @@ async def on_startup(): if not db_ready: logger.error("Database initialization failed. Shutting down.") import sys - sys.exit(1) - # Start background health monitor - _health_monitor.start() - logger.info("Health monitor started") - + # Seed built-in data + await seed_builtin_tool_types() logger.info("Startup complete.") - -@app.on_event("shutdown") -async def on_shutdown(): - logger.info("Shutting down Headquarter API...") - _health_monitor.stop() - logger.info("Health monitor stopped") - logger.info("Shutdown complete.") - - app.include_router(health_router) app.include_router(auth_router) app.include_router(dashboard_router) @@ -155,17 +126,11 @@ app.include_router(ssh_keys_router) app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) -app.include_router(tool_definitions_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) app.include_router(instance_proxy_router) app.include_router(terminal_router) -app.include_router(events_router) -app.include_router(notifications_router) -app.include_router(all_workspaces_router) -app.include_router(workspaces_router) -app.include_router(workspace_files_router) -app.include_router(workspace_git_router) -app.include_router(workspace_instances_router) app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 0ea612d..3358553 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,13 +1,11 @@ from src.models.base import Base -from src.models.config_profile import ConfigProfile, ConfigProfileInclude +from src.models.config_folder import ConfigFolder +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository -from src.models.health_check import HealthCheck -from src.models.instance_event import InstanceEvent -from src.models.notification import Notification from src.models.project import Project from src.models.ssh_key import SSHKey -from src.models.terminal_session import TerminalSessionModel -from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models.tool_instance import ToolInstance from src.models.tool_type import ToolType from src.models.user import User @@ -15,16 +13,13 @@ from src.models.user_config import UserConfig __all__ = [ "Base", + "ConfigFolder", + "ConfigInclude", + "ConfigMount", "ConfigProfile", - "ConfigProfileInclude", "GitRepository", - "HealthCheck", - "InstanceEvent", - "Notification", "Project", "SSHKey", - "TerminalSessionModel", - "ToolDefinitionManifest", "ToolInstance", "ToolType", "User", diff --git a/apps/api/src/models/config_folder.py b/apps/api/src/models/config_folder.py new file mode 100644 index 0000000..9c232fe --- /dev/null +++ b/apps/api/src/models/config_folder.py @@ -0,0 +1,33 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, ForeignKey, JSON, String, Text +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.user import User + + +class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_folders" + + 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) + mount_path: Mapped[str] = mapped_column(String(1024), nullable=False) + files: Mapped[dict] = mapped_column( + JSON, default=dict, nullable=False + ) # {"relative/path": "content", ...} + project_overrides: Mapped[dict | None] = mapped_column( + JSON, default=dict, nullable=True + ) # {"project_id": {"mount_path": "...", "files": {...}}} + # DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time + # auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead. + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + user: Mapped["User"] = relationship() diff --git a/apps/api/src/models/config_include.py b/apps/api/src/models/config_include.py new file mode 100644 index 0000000..90cc2c8 --- /dev/null +++ b/apps/api/src/models/config_include.py @@ -0,0 +1,36 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Integer, UniqueConstraint +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.config_profile import ConfigProfile + + +class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_includes" + __table_args__ = ( + UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"), + ) + + profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + included_profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[profile_id], + back_populates="includes", + ) + included_profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[included_profile_id], + ) diff --git a/apps/api/src/models/config_mount.py b/apps/api/src/models/config_mount.py new file mode 100644 index 0000000..de112a3 --- /dev/null +++ b/apps/api/src/models/config_mount.py @@ -0,0 +1,31 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Integer, JSON, String +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.config_profile import ConfigProfile + + +class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_mounts" + + profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + target_path: Mapped[str] = mapped_column(String(1024), nullable=False) + mode: Mapped[str] = mapped_column(String(10), nullable=False, default="rw") + files: Mapped[dict[str, str] | None] = mapped_column( + JSON, default=dict, nullable=True + ) + order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[profile_id], + back_populates="mounts", + ) diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index 04eae6f..2797241 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -1,13 +1,15 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, JSON, Integer, String, Text, Boolean +from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: + from src.models.config_include import ConfigInclude + from src.models.config_mount import ConfigMount from src.models.project import Project from src.models.tool_type import ToolType from src.models.user import User @@ -15,63 +17,43 @@ if TYPE_CHECKING: class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "config_profiles" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), + ) 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", ...} - git_mounts: Mapped[list] = mapped_column( - JSON, default=list, nullable=False - ) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...] - is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + environment_variables: Mapped[dict[str, str] | None] = mapped_column( + JSON, default=dict, nullable=True + ) + start_command: Mapped[str | None] = mapped_column(Text, nullable=True) + working_directory: Mapped[str | None] = mapped_column(Text, nullable=True) + port: Mapped[int | None] = mapped_column(Integer, nullable=True) + is_default: Mapped[bool] = mapped_column(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", + includes: Mapped[list["ConfigInclude"]] = relationship( + "ConfigInclude", + primaryjoin="ConfigProfile.id == ConfigInclude.profile_id", + back_populates="profile", cascade="all, delete-orphan", + order_by="ConfigInclude.order_index", ) - - -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], + mounts: Mapped[list["ConfigMount"]] = relationship( + "ConfigMount", + primaryjoin="ConfigProfile.id == ConfigMount.profile_id", + back_populates="profile", + cascade="all, delete-orphan", + order_by="ConfigMount.order_index", ) diff --git a/apps/api/src/models/git_repository.py b/apps/api/src/models/git_repository.py index d6f99fd..5e8aa74 100644 --- a/apps/api/src/models/git_repository.py +++ b/apps/api/src/models/git_repository.py @@ -10,7 +10,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: from src.models.project import Project - from src.models.ssh_key import SSHKey from src.models.user import User @@ -19,15 +18,11 @@ class GitRepository(UUIDPrimaryKeyMixin, TimestampMixin, Base): name: Mapped[str] = mapped_column(String(255)) path: Mapped[str] = mapped_column(String(1024)) - project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=True) + project_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("projects.id"), nullable=False) owner_id: Mapped[uuid.UUID] = mapped_column(UUID(), ForeignKey("users.id"), nullable=False) is_mirror: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) remote_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) last_push: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - ssh_key_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(), ForeignKey("ssh_keys.id"), nullable=True - ) project: Mapped["Project"] = relationship(back_populates="repositories") owner: Mapped["User"] = relationship() - ssh_key: Mapped["SSHKey | None"] = relationship() diff --git a/apps/api/src/models/tool_config.py b/apps/api/src/models/tool_config.py new file mode 100644 index 0000000..87e043b --- /dev/null +++ b/apps/api/src/models/tool_config.py @@ -0,0 +1,48 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, JSON, String, Text +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 ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "tool_configs" + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("users.id"), nullable=False + ) + tool_type_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("tool_types.id"), nullable=False + ) + project_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("projects.id"), nullable=True + ) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + config_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="env" + ) # "env" or "file" + file_path: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) # Only for file type + port_override: Mapped[int | None] = mapped_column(nullable=True) + start_command: Mapped[str | None] = mapped_column(Text, nullable=True) + working_directory: Mapped[str | None] = mapped_column(Text, nullable=True) + environment_variables: Mapped[dict | None] = mapped_column( + JSON, default=dict, nullable=True + ) + volumes: Mapped[list[dict] | None] = mapped_column( + JSON, default=list, nullable=True + ) + + user: Mapped["User"] = relationship() + tool_type: Mapped["ToolType"] = relationship() + project: Mapped["Project | None"] = relationship() diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index ddca249..4fa0a89 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String +from sqlalchemy import DateTime, ForeignKey, Integer, String from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -14,7 +14,6 @@ if TYPE_CHECKING: from src.models.project import Project from src.models.tool_type import ToolType from src.models.user import User - from src.models.workspace import Workspace class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): @@ -34,40 +33,42 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): owner_id: Mapped[uuid.UUID] = mapped_column( UUID(), ForeignKey("users.id"), nullable=False ) - status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending") - container_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - container_name: Mapped[str | None] = mapped_column(String(255), nullable=True) - compose_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) - url: Mapped[str | None] = mapped_column(String(1024), nullable=True) - public_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) - tunnel_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - port: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[str] = mapped_column( + String(50), nullable=False, default="pending" + ) + container_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + container_name: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + compose_path: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) + url: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) + public_url: Mapped[str | None] = mapped_column( + String(1024), nullable=True + ) + tunnel_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + port: Mapped[int | None] = mapped_column( + Integer, nullable=True + ) last_started_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) last_stopped_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) - manifest_compiled_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - image_tag: Mapped[str | None] = mapped_column(String(256), nullable=True) - probe_result: Mapped[dict | None] = mapped_column(JSON, nullable=True) - clone_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="mount") - branch: Mapped[str | None] = mapped_column( - String(255), nullable=True, default="main" - ) - selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column( + selected_profile_id: Mapped[uuid.UUID | None] = mapped_column( UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True ) - ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) - workspace_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True - ) tool_type: Mapped["ToolType"] = relationship() - workspace: Mapped["Workspace | None"] = relationship() repository: Mapped["GitRepository"] = relationship() project: Mapped["Project"] = relationship() owner: Mapped["User"] = relationship() - selected_config_profile: Mapped["ConfigProfile | None"] = relationship() + selected_profile: Mapped["ConfigProfile | None"] = relationship() diff --git a/apps/api/src/models/tool_type.py b/apps/api/src/models/tool_type.py index 24b93dc..57e41ec 100644 --- a/apps/api/src/models/tool_type.py +++ b/apps/api/src/models/tool_type.py @@ -8,7 +8,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: - from src.models.tool_definition_manifest import ToolDefinitionManifest from src.models.user import User @@ -19,36 +18,23 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base): display_name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") - interface_type: Mapped[str] = mapped_column( - String(20), nullable=False, default="web" - ) - requires_port: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + interfaces: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) default_port: Mapped[int] = mapped_column(nullable=False) definition_type: Mapped[str] = mapped_column( - String(16), nullable=False, default="legacy" - ) # "legacy" | "manifest" - manifest_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(), - ForeignKey("tool_definition_manifests.id"), - nullable=True, - ) + String(20), nullable=False, default="compose" + ) # "compose" or "dockerfile" compose_template: Mapped[str | None] = mapped_column(Text, nullable=True) dockerfile_template: Mapped[str | None] = mapped_column(Text, nullable=True) build_context: Mapped[dict | None] = mapped_column( JSON, default=dict, nullable=True ) readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True) - startup_command: Mapped[str | None] = mapped_column(Text, nullable=True) - required_variables: Mapped[list[str]] = mapped_column( - JSON, default=list, nullable=False - ) + required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_by_id: Mapped[uuid.UUID | None] = mapped_column( UUID(), ForeignKey("users.id"), nullable=True, ) - manifest: Mapped["ToolDefinitionManifest | None"] = relationship( - foreign_keys=[manifest_id], - ) created_by: Mapped["User | None"] = relationship() diff --git a/apps/api/src/models/user_config.py b/apps/api/src/models/user_config.py index 169de24..fae0c1a 100644 --- a/apps/api/src/models/user_config.py +++ b/apps/api/src/models/user_config.py @@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False) user: Mapped["User"] = relationship(back_populates="user_config") + + @property + def default_profile_id(self) -> uuid.UUID | None: + profile_id = self.config.get("default_profile_id") + return uuid.UUID(profile_id) if profile_id else None + + @default_profile_id.setter + def default_profile_id(self, value: uuid.UUID | None) -> None: + if value is not None: + self.config["default_profile_id"] = str(value) + elif "default_profile_id" in self.config: + del self.config["default_profile_id"] + + @property + def default_profiles(self) -> dict[str, str]: + return self.config.get("default_profiles", {}) + + @default_profiles.setter + def default_profiles(self, value: dict[str, str]) -> None: + self.config["default_profiles"] = value diff --git a/apps/api/src/schemas/__init__.py b/apps/api/src/schemas/__init__.py new file mode 100644 index 0000000..00969af --- /dev/null +++ b/apps/api/src/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic request/response schemas.""" diff --git a/apps/api/src/schemas/config_folder.py b/apps/api/src/schemas/config_folder.py new file mode 100644 index 0000000..51d6af5 --- /dev/null +++ b/apps/api/src/schemas/config_folder.py @@ -0,0 +1,44 @@ +"""Config folder request/response schemas.""" + +import uuid + +from pydantic import BaseModel, Field + + +class ConfigFolderCreate(BaseModel): + name: str = Field(description="Folder name") + description: str | None = Field(default=None, description="Optional description") + mount_path: str = Field(description="Mount path in container") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + is_active: bool = Field(default=True, description="Whether folder is active") + + +class ConfigFolderUpdate(BaseModel): + name: str | None = None + description: str | None = None + mount_path: str | None = None + files: dict[str, str] | None = None + is_active: bool | None = None + + +class ProjectOverrideCreate(BaseModel): + project_id: str = Field(description="Project ID to override for") + mount_path: str | None = Field(default=None, description="Override mount path") + files: dict[str, str] | None = Field( + default=None, description="Override files" + ) + is_active: bool | None = Field(default=None, description="Override active state") + + +class ConfigFolderResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + mount_path: str + files: dict[str, str] | None + is_active: bool + created_at: str + updated_at: str diff --git a/apps/api/src/schemas/config_profile.py b/apps/api/src/schemas/config_profile.py new file mode 100644 index 0000000..5a6579a --- /dev/null +++ b/apps/api/src/schemas/config_profile.py @@ -0,0 +1,131 @@ +"""Config profile request/response schemas.""" + +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +MAX_MOUNT_PATH_LENGTH = 1024 + + +class ConfigProfileCreate(BaseModel): + name: str = Field(description="Profile name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileUpdate(BaseModel): + name: str | None = Field(default=None, description="Profile name") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + created_at: str + updated_at: str + + +class ConfigProfileDetailResponse(ConfigProfileResponse): + includes: list[dict[str, Any]] + mounts: list[dict[str, Any]] + + +class ConfigIncludeCreate(BaseModel): + included_profile_id: str = Field(description="UUID of the profile to include") + order_index: int = Field(default=0, description="Order index for include resolution") + + +class ConfigIncludeUpdate(BaseModel): + order_index: int = Field(description="Order index for include resolution") + + +class ConfigIncludeResponse(BaseModel): + id: str + profile_id: str + included_profile_id: str + included_profile_name: str | None + order_index: int + created_at: str + updated_at: str + + +class ConfigMountCreate(BaseModel): + target_path: str = Field(description="Absolute target path in container") + mode: str = Field(default="rw", description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + order_index: int = Field(default=0, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str) -> str: + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountUpdate(BaseModel): + target_path: str | None = Field(default=None, description="Absolute target path in container") + mode: str | None = Field(default=None, description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field( + default=None, description="Files as {path: content}" + ) + order_index: int | None = Field(default=None, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str | None) -> str | None: + if v is None: + return v + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountResponse(BaseModel): + id: str + profile_id: str + target_path: str + mode: str + files: dict[str, str] | None + order_index: int + created_at: str + updated_at: str + + +class DefaultProfilesUpdate(BaseModel): + default_profiles: dict[str, str] = Field( + description="Mapping of tool_type_id to profile_id" + ) diff --git a/apps/api/src/schemas/git_repository.py b/apps/api/src/schemas/git_repository.py new file mode 100644 index 0000000..05672af --- /dev/null +++ b/apps/api/src/schemas/git_repository.py @@ -0,0 +1,129 @@ +"""Git repository request/response schemas.""" + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class GitRepositoryCreate(BaseModel): + name: str + remote_url: str | None = None + force_original_url: bool = False + + +class URLParseRequest(BaseModel): + url: str + + +class URLParseResponse(BaseModel): + original_url: str + base_url: str | None + is_valid_clone_url: bool + needs_parsing: bool + host: str | None + message: str + error_code: str | None + + +class GitRepositoryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + path: str + project_id: uuid.UUID + owner_id: uuid.UUID + is_mirror: bool + remote_url: str | None + last_push: datetime | None + created_at: datetime + updated_at: datetime + + +class FileListResponse(BaseModel): + path: str + branch: str + entries: list[dict] + + +class FileContentResponse(BaseModel): + path: str + branch: str + content: str + size: int + encoding: str + language: str | None + is_binary: bool + last_commit: dict | None + + +class BranchesResponse(BaseModel): + branches: list[dict] + default_branch: str + + +class FileUpdateRequest(BaseModel): + path: str + branch: str + content: str + commit_message: str + + +class FileUpdateResponse(BaseModel): + commit_hash: str + message: str + branch: str + + +class StatusResponse(BaseModel): + branch: str + modified: list[str] + added: list[str] + deleted: list[str] + untracked: list[str] + renamed: list[str] + ahead: int + behind: int + + +class BranchCreateRequest(BaseModel): + name: str + base_branch: str = "HEAD" + + +class CheckoutRequest(BaseModel): + branch: str + + +class CommitRequest(BaseModel): + message: str + files: list[str] | None = None + + +class CommitResponse(BaseModel): + commit_hash: str + message: str + + +class FetchResponse(BaseModel): + message: str + + +class PullResponse(BaseModel): + message: str + + +class PushResponse(BaseModel): + message: str + + +class MergeRequest(BaseModel): + source_branch: str + target_branch: str | None = None + message: str | None = None + + +class MergeResponse(BaseModel): + commit_hash: str + message: str diff --git a/apps/api/src/schemas/health.py b/apps/api/src/schemas/health.py new file mode 100644 index 0000000..4aab904 --- /dev/null +++ b/apps/api/src/schemas/health.py @@ -0,0 +1,50 @@ +"""Health check response schemas.""" + +from pydantic import BaseModel, Field + + +class DatabaseHealth(BaseModel): + """Database health check result.""" + + status: str = Field(description="Database health status", examples=["healthy"]) + response_time_ms: float = Field( + description="Query response time in milliseconds", examples=[5.2] + ) + + +class DiskHealth(BaseModel): + """Disk space health check result.""" + + status: str = Field(description="Disk health status", examples=["healthy"]) + free_gb: float = Field(description="Free disk space in GB", examples=[45.2]) + total_gb: float = Field(description="Total disk space in GB", examples=[100.0]) + + +class HealthChecks(BaseModel): + """Individual health checks.""" + + database: DatabaseHealth | None = None + disk: DiskHealth | None = None + + +class HealthResponse(BaseModel): + """Overall health check response.""" + + status: str = Field(description="Overall health status", examples=["healthy"]) + timestamp: str = Field( + description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"] + ) + version: str = Field(description="API version", examples=["0.1.0"]) + checks: HealthChecks = Field(description="Individual health checks") + uptime_seconds: float = Field( + description="Server uptime in seconds", examples=[3600.0] + ) + + +class DatabaseHealthResponse(BaseModel): + """Database-specific health check response.""" + + status: str = Field(description="Database health status", examples=["healthy"]) + response_time_ms: float = Field( + description="Query response time in milliseconds", examples=[5.2] + ) diff --git a/apps/api/src/schemas/project.py b/apps/api/src/schemas/project.py new file mode 100644 index 0000000..fb80dbf --- /dev/null +++ b/apps/api/src/schemas/project.py @@ -0,0 +1,25 @@ +"""Project request/response schemas.""" + +from pydantic import BaseModel + + +class ProjectCreate(BaseModel): + name: str + description: str | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = None + description: str | None = None + + +class ProjectResponse(BaseModel): + id: str + name: str + description: str | None + created_at: str + updated_at: str + + +class SetDefaultSSHKeyRequest(BaseModel): + ssh_key_id: str diff --git a/apps/api/src/schemas/ssh_key.py b/apps/api/src/schemas/ssh_key.py new file mode 100644 index 0000000..33e1d41 --- /dev/null +++ b/apps/api/src/schemas/ssh_key.py @@ -0,0 +1,16 @@ +"""SSH key request/response schemas.""" + +from pydantic import BaseModel + + +class SSHKeyCreate(BaseModel): + name: str + public_key: str + + +class SSHKeyResponse(BaseModel): + id: str + name: str + public_key: str + fingerprint: str + created_at: str diff --git a/apps/api/src/schemas/tool_config.py b/apps/api/src/schemas/tool_config.py new file mode 100644 index 0000000..3fc2781 --- /dev/null +++ b/apps/api/src/schemas/tool_config.py @@ -0,0 +1,47 @@ +"""Tool config request/response schemas.""" + +from pydantic import BaseModel, Field + + +class ToolConfigCreate(BaseModel): + tool_type_id: str = Field(description="UUID of the tool type") + key: str = Field(description="Configuration key") + value: str = Field(description="Configuration value") + config_type: str = Field(default="env", description="Config type: env or file") + file_path: str | None = Field(default=None, description="File path for file configs") + port_override: int | None = Field(default=None, description="Port override") + start_command: str | None = Field(default=None, description="Start command override") + working_directory: str | None = Field(default=None, description="Working directory") + environment_variables: dict[str, str] | None = Field( + default=None, description="Additional environment variables" + ) + volumes: list[dict] | None = Field(default=None, description="Volume mounts") + + +class ToolConfigUpdate(BaseModel): + value: str | None = None + config_type: str | None = None + file_path: str | None = None + port_override: int | None = None + start_command: str | None = None + working_directory: str | None = None + environment_variables: dict[str, str] | None = None + volumes: list[dict] | None = None + + +class ToolConfigResponse(BaseModel): + id: str + tool_type_id: str + user_id: str + project_id: str | None + key: str + value: str + config_type: str + file_path: str | None + port_override: int | None + start_command: str | None + working_directory: str | None + environment_variables: dict[str, str] | None + volumes: list[dict] | None + created_at: str + updated_at: str diff --git a/apps/api/src/schemas/tool_instance.py b/apps/api/src/schemas/tool_instance.py new file mode 100644 index 0000000..09454d7 --- /dev/null +++ b/apps/api/src/schemas/tool_instance.py @@ -0,0 +1,41 @@ +"""Tool instance request/response schemas.""" + +from pydantic import BaseModel, Field + + +class CreateInstanceRequest(BaseModel): + """Request body for creating a tool instance.""" + + model_config = {"extra": "ignore"} + + tool_type_id: str = Field(description="UUID of the tool type to instantiate") + display_name: str | None = Field( + default=None, description="Optional display name for the instance" + ) + config_profile_id: str | None = Field( + default=None, description="Optional config profile ID to apply to the instance" + ) + + +class SessionItemResponse(BaseModel): + """Lightweight session summary for sidebar and dashboard.""" + + model_config = {"extra": "ignore"} + + id: str = Field(description="Session (tool instance) ID") + display_name: str = Field(description="Display name of the session") + tool_type_name: str = Field(description="Name of the tool type") + tool_icon: str | None = Field(default=None, description="Icon URL for the tool type") + tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces") + repository_name: str = Field(description="Name of the repository") + repository_id: str = Field(description="Repository ID") + project_name: str = Field(description="Name of the project") + project_id: str = Field(description="Project ID") + status: str = Field(description="Current status") + url: str | None = Field(default=None, description="Access URL") + + +class SessionListResponse(BaseModel): + """Response wrapping a list of session summaries.""" + + sessions: list[SessionItemResponse] diff --git a/apps/api/src/schemas/tool_type.py b/apps/api/src/schemas/tool_type.py new file mode 100644 index 0000000..ff449aa --- /dev/null +++ b/apps/api/src/schemas/tool_type.py @@ -0,0 +1,204 @@ +"""Tool type request/response schemas.""" + +import uuid +from datetime import datetime + +import yaml +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + + +class ToolTypeCreate(BaseModel): + name: str + display_name: str + description: str | None = None + default_port: int + definition_type: str = "compose" + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + required_variables: list[str] = [] + category: str = "other" + interfaces: list[str] = ["web"] + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str) -> str: + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "compose": + return v + if v is None: + raise ValueError("compose_template is required when definition_type is 'compose'") + try: + parsed = yaml.safe_load(v) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML: {e}") + if not isinstance(parsed, dict): + raise ValueError("Compose template must be a YAML mapping") + if "services" not in parsed: + raise ValueError("Compose template must contain 'services' key") + if not parsed["services"]: + raise ValueError("Compose template must define at least one service") + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + data = info.data + if data.get("definition_type") != "dockerfile": + return v + if v is None: + raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + return v + + @field_validator("default_port") + @classmethod + def validate_default_port(cls, v: int, info) -> int: + if v <= 0 or v > 65535: + raise ValueError("Port must be between 1 and 65535") + data = info.data + if data.get("definition_type") != "compose": + return v + template = data.get("compose_template") + if not template: + return v + try: + parsed = yaml.safe_load(template) + except yaml.YAMLError: + return v + port_str = str(v) + port_exposed = False + if isinstance(parsed, dict) and "services" in parsed: + for service_config in parsed["services"].values(): + if isinstance(service_config, dict) and "ports" in service_config: + for port_mapping in service_config["ports"]: + if isinstance(port_mapping, str) and port_str in port_mapping: + port_exposed = True + break + elif isinstance(port_mapping, int) and port_mapping == v: + port_exposed = True + break + if port_exposed: + break + if not port_exposed: + raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.") + return v + + @field_validator("required_variables") + @classmethod + def validate_required_variables(cls, v: list[str], info) -> list[str]: + if not v: + return v + data = info.data + if data.get("definition_type") != "compose": + return v + template = data.get("compose_template") + if not template: + return v + for var in v: + placeholder = f"{{{{{var}}}}}" + if placeholder not in template: + raise ValueError(f"Required variable '{var}' not found in compose template") + return v + + @model_validator(mode="after") + def validate_templates(self) -> "ToolTypeCreate": + if self.definition_type == "dockerfile" and self.dockerfile_template is None: + raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'") + if self.definition_type == "compose" and self.compose_template is None: + raise ValueError("compose_template is required when definition_type is 'compose'") + return self + + +class ToolTypeUpdate(BaseModel): + display_name: str | None = None + description: str | None = None + default_port: int | None = None + definition_type: str | None = None + compose_template: str | None = None + dockerfile_template: str | None = None + build_context: dict | None = None + readiness_probe: dict | None = None + required_variables: list[str] | None = None + category: str | None = None + interfaces: list[str] | None = None + + @field_validator("definition_type") + @classmethod + def validate_definition_type(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("compose", "dockerfile"): + raise ValueError("definition_type must be 'compose' or 'dockerfile'") + return v + + @field_validator("compose_template") + @classmethod + def validate_compose_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "compose": + return v + try: + parsed = yaml.safe_load(v) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML: {e}") + if not isinstance(parsed, dict): + raise ValueError("Compose template must be a YAML mapping") + if "services" not in parsed: + raise ValueError("Compose template must contain 'services' key") + if not parsed["services"]: + raise ValueError("Compose template must define at least one service") + return v + + @field_validator("dockerfile_template") + @classmethod + def validate_dockerfile_template(cls, v: str | None, info) -> str | None: + if v is None: + return v + data = info.data + definition_type = data.get("definition_type") + if definition_type and definition_type != "dockerfile": + return v + if not v.strip().startswith("FROM"): + raise ValueError("Dockerfile must start with a FROM instruction") + return v + + +class ToolTypeResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + display_name: str + description: str | None + category: str + interfaces: list[str] + default_port: int + definition_type: str + compose_template: str | None + dockerfile_template: str | None + build_context: dict | None + readiness_probe: dict | None + required_variables: list[str] + is_builtin: bool + created_by_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + +class ToolTypeValidateRequest(BaseModel): + definition_type: str + compose_template: str | None = None + dockerfile_template: str | None = None diff --git a/apps/api/src/schemas/user.py b/apps/api/src/schemas/user.py new file mode 100644 index 0000000..d890683 --- /dev/null +++ b/apps/api/src/schemas/user.py @@ -0,0 +1,19 @@ +"""User request/response schemas.""" + +import uuid + +from pydantic import BaseModel, ConfigDict + + +class UserProfileResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + name: str + avatar_url: str | None + + +class UserProfileUpdate(BaseModel): + name: str | None = None + email: str | None = None diff --git a/apps/api/src/schemas/user_config.py b/apps/api/src/schemas/user_config.py new file mode 100644 index 0000000..a013d47 --- /dev/null +++ b/apps/api/src/schemas/user_config.py @@ -0,0 +1,21 @@ +"""User config request/response schemas.""" + +from pydantic import BaseModel, ConfigDict + + +class UserConfigResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + default_editor: str | None = None + theme: str = "system" + git_user_name: str | None = None + git_user_email: str | None = None + last_session_id: str | None = None + + +class UserConfigUpdate(BaseModel): + default_editor: str | None = None + theme: str | None = None + git_user_name: str | None = None + git_user_email: str | None = None + last_session_id: str | None = None diff --git a/apps/api/src/seeds/__init__.py b/apps/api/src/seeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/seeds/builtin_tool_types.py b/apps/api/src/seeds/builtin_tool_types.py new file mode 100644 index 0000000..023b2d1 --- /dev/null +++ b/apps/api/src/seeds/builtin_tool_types.py @@ -0,0 +1,161 @@ +import logging + +from sqlalchemy import select + +from src.database import SessionLocal +from src.models.tool_type import ToolType + +logger = logging.getLogger(__name__) + + +async def _table_exists(session, table_name: str) -> bool: + """Check if a table exists in the database.""" + from sqlalchemy import text + + try: + result = await session.execute( + text( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = :table_name + ) + """ + ), + {"table_name": table_name}, + ) + return result.scalar() or False + except Exception: + return False + + +async def seed_builtin_tool_types(): + async with SessionLocal() as session: + # Check if tool_types table exists before attempting to seed + if not await _table_exists(session, "tool_types"): + logger.warning( + "tool_types table does not exist. Skipping seeding. " + "Migrations may not have run yet." + ) + return + + builtin_types = [ + { + "name": "code-server", + "display_name": "VS Code Server", + "description": "VS Code running in the browser via code-server", + "category": "editor", + "interfaces": ["web"], + "compose_template": """version: "3.8" +services: + code-server: + image: lscr.io/linuxserver/code-server:latest + container_name: {{TOOL_NAME}} + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/London + volumes: + - {{REPO_PATH}}:/config/workspace + ports: + - "8443:8443" + restart: unless-stopped""", + "default_port": 8443, + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + { + "name": "jupyter-notebook", + "display_name": "Jupyter Notebook", + "description": "Jupyter Lab for interactive development", + "category": "notebook", + "interfaces": ["web"], + "default_port": 8888, + "compose_template": """version: "3.8" +services: + jupyter: + image: jupyter/scipy-notebook:latest + container_name: {{TOOL_NAME}} + environment: + - JUPYTER_ENABLE_LAB=yes + volumes: + - {{REPO_PATH}}:/home/jovyan/work + ports: + - "8888:8888" + restart: unless-stopped""", + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + { + "name": "opencode", + "display_name": "OpenCode", + "description": "AI coding assistant - run opencode in terminal", + "category": "ai-assistant", + "interfaces": ["terminal"], + "default_port": 3000, + "compose_template": """version: "3.8" +services: + opencode: + image: node:20-slim + container_name: {{TOOL_NAME}} + working_dir: /workspace + environment: + - HOME=/tmp + volumes: + - {{REPO_PATH}}:/workspace + - opencode_home:/tmp + ports: + - "3000:3000" + command: > + sh -c "set -x && + apt-get update && apt-get install -y git ca-certificates && + echo 'Installing opencode...' && + npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' && + which opencode || echo 'ERROR: opencode not in PATH' && + npm bin -g && + ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' && + echo 'export PATH=\"$(npm bin -g):\\$PATH\"' >> /root/.bashrc && + echo 'cd /workspace' >> /root/.bashrc && + echo 'OpenCode installation complete' && + cd /workspace && + exec tail -f /dev/null" + stdin_open: true + tty: true + restart: unless-stopped + +volumes: + opencode_home:""", + "required_variables": ["REPO_PATH", "TOOL_NAME"], + }, + ] + + for tool_data in builtin_types: + existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"])) + if not existing: + tool_type = ToolType( + name=tool_data["name"], + display_name=tool_data["display_name"], + description=tool_data["description"], + category=tool_data["category"], + interfaces=tool_data["interfaces"], + definition_type="compose", + compose_template=tool_data["compose_template"], + required_variables=tool_data["required_variables"], + default_port=tool_data.get("default_port"), + is_builtin=True, + ) + session.add(tool_type) + logger.info("Created built-in tool type: %s", tool_data["name"]) + else: + # Update existing built-in tool types to reflect code changes + existing.display_name = tool_data["display_name"] + existing.description = tool_data["description"] + existing.category = tool_data["category"] + existing.interfaces = tool_data["interfaces"] + existing.definition_type = "compose" + existing.compose_template = tool_data["compose_template"] + existing.required_variables = tool_data["required_variables"] + existing.default_port = tool_data.get("default_port") + logger.info("Updated built-in tool type: %s", tool_data["name"]) + + await session.commit() + logger.info("Built-in tool types seeded successfully.") diff --git a/apps/api/src/services/config_profiles.py b/apps/api/src/services/config_profiles.py new file mode 100644 index 0000000..daba56c --- /dev/null +++ b/apps/api/src/services/config_profiles.py @@ -0,0 +1,299 @@ +"""Config profile business logic.""" + +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile +from src.models.tool_type import ToolType +from src.models.user_config import UserConfig + +logger = logging.getLogger(__name__) + +MAX_INCLUDES_DEPTH = 10 + + +async def get_owned_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID, + session: AsyncSession, +) -> ConfigProfile: + """Fetch a config profile and verify ownership.""" + profile = await session.get(ConfigProfile, profile_id) + if profile is None or profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="config profile not found", + ) + return profile + + +async def _detect_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + visited: set[uuid.UUID] | None = None, + depth: int = 0, +) -> bool: + """Detect cycles in profile includes using DFS. + + Returns True if a cycle is detected. + """ + if depth > MAX_INCLUDES_DEPTH: + return True + + if visited is None: + visited = set() + + if profile_id in visited: + return True + + visited.add(profile_id) + + result = await session.execute( + select(ConfigInclude.included_profile_id).where( + ConfigInclude.profile_id == profile_id + ) + ) + included_ids = result.scalars().all() + + for included_id in included_ids: + if await _detect_cycle(session, included_id, visited.copy(), depth + 1): + return True + + return False + + +async def validate_includes_no_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + new_included_id: uuid.UUID | None = None, +) -> None: + """Validate that adding an include wouldn't create a cycle.""" + if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="adding this include would create a circular reference", + ) + + +# --------------------------------------------------------------------------- +# Profile CRUD helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_name( + session: AsyncSession, + user_id: uuid.UUID, + name: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Raise 409 if a profile with the given name already exists.""" + query = select(ConfigProfile).where( + ConfigProfile.user_id == user_id, + ConfigProfile.name == name, + ) + if exclude_id: + query = query.where(ConfigProfile.id != exclude_id) + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config profile with name '{name}' already exists", + ) + + +def profile_to_dict(profile: ConfigProfile) -> dict: + """Serialize a ConfigProfile to a dict.""" + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "name": profile.name, + "description": profile.description, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Include helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_include( + session: AsyncSession, + profile_id: uuid.UUID, + included_profile_id: uuid.UUID, +) -> None: + """Raise 409 if the include already exists.""" + existing = await session.scalar( + select(ConfigInclude).where( + ConfigInclude.profile_id == profile_id, + ConfigInclude.included_profile_id == included_profile_id, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="this include already exists", + ) + + +def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict: + """Serialize a ConfigInclude to a dict.""" + return { + "id": str(inc.id), + "profile_id": str(inc.profile_id), + "included_profile_id": str(inc.included_profile_id), + "included_profile_name": included_name, + "order_index": inc.order_index, + "created_at": inc.created_at.isoformat() if inc.created_at else None, + "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Mount helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_mount_path( + session: AsyncSession, + profile_id: uuid.UUID, + target_path: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Raise 409 if a mount with the given path already exists.""" + query = select(ConfigMount).where( + ConfigMount.profile_id == profile_id, + ConfigMount.target_path == target_path, + ) + if exclude_id: + query = query.where(ConfigMount.id != exclude_id) + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"mount with path '{target_path}' already exists", + ) + + +def mount_to_dict(mount: ConfigMount) -> dict: + """Serialize a ConfigMount to a dict.""" + return { + "id": str(mount.id), + "profile_id": str(mount.profile_id), + "target_path": mount.target_path, + "files": mount.files, + "mode": mount.mode, + "order_index": mount.order_index, + "created_at": mount.created_at.isoformat() if mount.created_at else None, + "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Default profile helpers +# --------------------------------------------------------------------------- + +async def get_or_create_user_config( + session: AsyncSession, + user_id: uuid.UUID, +) -> UserConfig: + """Get existing user config or create a new one.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + if user_config is None: + user_config = UserConfig(user_id=user_id, config={}) + session.add(user_config) + return user_config + + +async def validate_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, + default_profiles: dict[str, str], +) -> None: + """Validate that all profile IDs in default_profiles belong to the user.""" + for tool_type_id, profile_id_str in default_profiles.items(): + profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str)) + if profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found") + if profile.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user") + + +async def get_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, +) -> dict: + """Get default profiles for a user.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + return {"default_profiles": user_config.default_profiles if user_config else {}} + + +async def set_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, + default_profiles: dict[str, str], +) -> dict: + """Set default profiles for a user.""" + user_config = await get_or_create_user_config(session, user_id) + await validate_default_profiles(session, user_id, default_profiles) + user_config.config = {**user_config.config, "default_profiles": default_profiles} + await session.commit() + await session.refresh(user_config) + return {"default_profiles": user_config.default_profiles} + + +async def get_default_profile_for_tool_type( + session: AsyncSession, + user_id: uuid.UUID, + tool_type_id: str, +) -> dict: + """Get default profile for a specific tool type.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None + return {"tool_type_id": tool_type_id, "profile_id": profile_id} + + +# --------------------------------------------------------------------------- +# Include list helper +# --------------------------------------------------------------------------- + +async def list_includes_for_profile( + session: AsyncSession, + profile_id: uuid.UUID, +) -> dict: + """List all includes for a profile.""" + result = await session.execute( + select(ConfigInclude) + .where(ConfigInclude.profile_id == profile_id) + .order_by(ConfigInclude.order_index) + ) + includes_data = [] + for inc in result.scalars().all(): + included_profile = await session.get(ConfigProfile, inc.included_profile_id) + includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None)) + return {"includes": includes_data} + + +# --------------------------------------------------------------------------- +# Mount list helper +# --------------------------------------------------------------------------- + +async def list_mounts_for_profile( + session: AsyncSession, + profile_id: uuid.UUID, +) -> dict: + """List all mounts for a profile.""" + result = await session.execute( + select(ConfigMount) + .where(ConfigMount.profile_id == profile_id) + .order_by(ConfigMount.order_index) + ) + return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]} diff --git a/apps/api/src/services/docker/__init__.py b/apps/api/src/services/docker/__init__.py new file mode 100644 index 0000000..cd1dc61 --- /dev/null +++ b/apps/api/src/services/docker/__init__.py @@ -0,0 +1,44 @@ +"""Docker services for container and tunnel management.""" + +from .compose import ( + ensure_instance_directory, + execute_compose_command, + render_compose_template, + write_compose_file, + write_env_file, +) +from .config_staging import write_config_files, write_config_folder_files +from .container import ( + connect_container_to_network, + find_free_port, + get_container_id, + get_container_logs, + get_container_name, + get_container_status, +) +from .tunnel import ( + check_tunnel_health, + recreate_tunnel, + start_cloudflared_tunnel, + stop_cloudflared_tunnel, +) + +__all__ = [ + "render_compose_template", + "ensure_instance_directory", + "write_compose_file", + "write_env_file", + "execute_compose_command", + "write_config_files", + "write_config_folder_files", + "get_container_id", + "get_container_name", + "connect_container_to_network", + "get_container_status", + "get_container_logs", + "find_free_port", + "start_cloudflared_tunnel", + "stop_cloudflared_tunnel", + "recreate_tunnel", + "check_tunnel_health", +] diff --git a/apps/api/src/services/docker/compose.py b/apps/api/src/services/docker/compose.py new file mode 100644 index 0000000..a730e3c --- /dev/null +++ b/apps/api/src/services/docker/compose.py @@ -0,0 +1,237 @@ +"""Docker Compose file generation and command execution.""" + +import re +import subprocess +import uuid +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.config_profile import ConfigProfile +from src.models.tool_instance import ToolInstance +from src.services.profile_resolver import resolve_profile + + +def _sanitize_name(name: str) -> str: + """Sanitize a string for use in Docker/container names.""" + sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower()) + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.strip("-") + + +async def _generate_instance_name( + session: AsyncSession, + project_name: str, + tool_type_name: str, +) -> str: + """Generate a unique instance name: project-tool-NUM.""" + base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}" + base = base.strip("-") or "instance" + result = await session.execute( + select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%")) + ) + names = result.scalars().all() + max_num = 0 + for name in names: + parts = name.rsplit("-", 1) + if len(parts) == 2 and parts[0] == base and parts[1].isdigit(): + max_num = max(max_num, int(parts[1])) + return f"{base}-{max_num + 1:03d}" + + +def _modify_compose_file( + compose_path: str, + port_override: int | None = None, + start_command: str | None = None, + working_directory: str | None = None, + extra_volumes: list[dict] | None = None, +) -> None: + """Modify compose file with runtime overrides.""" + import yaml + + compose_file = Path(compose_path) + content = compose_file.read_text() + compose_data = yaml.safe_load(content) + + if not compose_data or "services" not in compose_data: + return + + for service_name, service_config in compose_data["services"].items(): + if port_override and "ports" in service_config: + for i, port_mapping in enumerate(service_config["ports"]): + if isinstance(port_mapping, str) and ":" in port_mapping: + _host_port, container_port = port_mapping.split(":", 1) + service_config["ports"][i] = f"{port_override}:{container_port}" + break + + if start_command: + service_config["command"] = start_command + + if working_directory: + service_config["working_dir"] = working_directory + + if extra_volumes: + if "volumes" not in service_config: + service_config["volumes"] = [] + for vol in extra_volumes: + source = vol.get("source", "") + target = vol.get("target", "") + vol_type = vol.get("type", "bind") + if vol_type == "bind": + service_config["volumes"].append(f"{source}:{target}") + else: + service_config["volumes"].append(f"{source}:{target}:{vol_type}") + + break + + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + + +async def _apply_resolved_profile( + profile: ConfigProfile, + instance_dir: str, + env_vars: dict[str, str], + port_override: int | None, + start_command: str | None, + working_directory: str | None, + extra_volumes: list[dict], +) -> tuple[dict[str, str], int | None, str | None, str | None, list[dict]]: + """Resolve a profile and apply its output to instance configuration.""" + resolved = resolve_profile(profile) + + if resolved.environment_variables: + env_vars.update(resolved.environment_variables) + + if resolved.runtime_hints.start_command is not None: + start_command = resolved.runtime_hints.start_command + if resolved.runtime_hints.working_directory is not None: + working_directory = resolved.runtime_hints.working_directory + if resolved.runtime_hints.port is not None: + port_override = resolved.runtime_hints.port + + for target_path, mount in resolved.mounts.items(): + safe_name = target_path.strip("/").replace("/", "_") + mount_dir = Path(instance_dir) / "mounts" / safe_name + mount_dir.mkdir(parents=True, exist_ok=True) + + for rel_path, content in mount.files.items(): + file_path = mount_dir / rel_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content) + + extra_volumes.append({ + "source": str(mount_dir), + "target": target_path, + "type": mount.mode, + }) + + return env_vars, port_override, start_command, working_directory, extra_volumes + + +def render_compose_template(template: str, variables: dict[str, Any]) -> str: + """Render a Docker Compose template with variable substitution. + + Args: + template: The compose template string + variables: Dictionary of variable names to values + + Returns: + Rendered compose file content + """ + result = template + for key, value in variables.items(): + placeholder = f"{{{{{key}}}}}" + result = result.replace(placeholder, str(value)) + return result + + +def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str: + """Create and return the instance directory path. + + Args: + instance_id: Unique instance identifier + base_path: Base directory for all instances (defaults to Settings.instance_base_path) + + Returns: + Absolute path to instance directory + """ + if base_path is None: + from src.config import Settings + base_path = Settings().instance_base_path + instance_dir = Path(base_path) / instance_id + instance_dir.mkdir(parents=True, exist_ok=True) + return str(instance_dir.absolute()) + + +def write_compose_file(instance_dir: str, content: str) -> str: + """Write the rendered compose file to the instance directory. + + Args: + instance_dir: Path to instance directory + content: Rendered compose content + + Returns: + Path to the compose file + """ + compose_path = Path(instance_dir) / "docker-compose.yml" + compose_path.write_text(content) + return str(compose_path) + + +def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str: + """Write environment variables to a .env file. + + Args: + instance_dir: Path to instance directory + env_vars: Dictionary of env var names to values + + Returns: + Path to the env file + """ + env_path = Path(instance_dir) / ".env" + lines = [f'{key}="{value}"' for key, value in env_vars.items()] + env_path.write_text("\n".join(lines) + "\n") + return str(env_path) + + +def execute_compose_command( + compose_path: str, action: str, timeout: int = 60, env_file: str | None = None +) -> tuple[int, str, str]: + """Execute a docker compose command. + + Args: + compose_path: Path to docker-compose.yml + action: The compose action (up, down, start, stop, restart) + timeout: Command timeout in seconds + env_file: Optional path to .env file for environment variables + + Returns: + Tuple of (returncode, stdout, stderr) + """ + instance_dir = Path(compose_path).parent + + cmd = ["docker", "compose", "-f", compose_path] + + if env_file: + cmd.extend(["--env-file", env_file]) + + if action == "up": + cmd.extend(["up", "-d"]) + elif action == "down": + cmd.extend(["down", "-v"]) + elif action in ("start", "stop", "restart"): + cmd.append(action) + else: + raise ValueError(f"Unknown compose action: {action}") + + result = subprocess.run( + cmd, + cwd=str(instance_dir), + capture_output=True, + text=True, + timeout=timeout, + ) + + return result.returncode, result.stdout, result.stderr diff --git a/apps/api/src/services/docker/config_staging.py b/apps/api/src/services/docker/config_staging.py new file mode 100644 index 0000000..817bcbf --- /dev/null +++ b/apps/api/src/services/docker/config_staging.py @@ -0,0 +1,79 @@ +"""Config folder file staging for Docker instances.""" + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def write_config_files(instance_dir: str, files: dict[str, str]) -> None: + """Write config files to the instance directory. + + Args: + instance_dir: Path to instance directory + files: Dictionary of file paths (relative to instance dir) to content + """ + instance_path = Path(instance_dir) + for file_path, content in files.items(): + # Ensure the path is within the instance directory (security) + full_path = instance_path / file_path + try: + full_path.resolve().relative_to(instance_path.resolve()) + except ValueError: + raise ValueError(f"File path '{file_path}' escapes instance directory") + + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + + +def write_config_folder_files(instance_dir: str, folders: list, project_id: str | None = None) -> list[dict]: + """Write config folder files to the instance directory and return volume mounts. + + Args: + instance_dir: Path to instance directory + folders: List of ConfigFolder objects + project_id: Optional project ID for applying overrides + + Returns: + List of volume mount dicts [{"source": "...", "target": "...", "type": "..."}] + """ + instance_path = Path(instance_dir) + volume_mounts = [] + + for folder in folders: + # Determine mount path (with project override if applicable) + mount_path = folder.mount_path + files = folder.files.copy() + + if project_id and folder.project_overrides: + override = folder.project_overrides.get(str(project_id)) + if override: + if override.get("mount_path"): + mount_path = override["mount_path"] + if override.get("files"): + files.update(override["files"]) + + # Write files to instance directory + folder_dir = instance_path / "volumes" / folder.name + folder_dir.mkdir(parents=True, exist_ok=True) + + for file_path, content in files.items(): + # Security: ensure path doesn't escape folder_dir + full_path = folder_dir / file_path + try: + full_path.resolve().relative_to(folder_dir.resolve()) + except ValueError: + logger.warning("Config folder file path escapes directory: %s", file_path) + continue + + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content) + + # Add volume mount + volume_mounts.append({ + "source": str(folder_dir), + "target": mount_path, + "type": "bind", + }) + + return volume_mounts diff --git a/apps/api/src/services/docker/container.py b/apps/api/src/services/docker/container.py new file mode 100644 index 0000000..e669e02 --- /dev/null +++ b/apps/api/src/services/docker/container.py @@ -0,0 +1,121 @@ +"""Docker container lifecycle and query operations.""" + +import socket +import subprocess + + +def get_container_id(instance_name: str) -> str | None: + """Get the container ID for a compose service. + + Args: + instance_name: The service name in compose + + Returns: + Container ID or None if not found + """ + result = subprocess.run( + ["docker", "ps", "-q", "--filter", f"name={instance_name}"], + capture_output=True, + text=True, + ) + + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().split("\n")[0] + return None + + +def get_container_name(instance_name: str) -> str | None: + """Get the full container name for a compose service. + + Args: + instance_name: The service name in compose + + Returns: + Container name or None if not found + """ + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}", "--filter", f"name={instance_name}"], + capture_output=True, + text=True, + ) + + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().split("\n")[0] + return None + + +def connect_container_to_network(container_name: str, network_name: str = "backend") -> bool: + """Connect a Docker container to an existing network. + + Args: + container_name: Name or ID of the container + network_name: Name of the Docker network (default: backend) + + Returns: + True if successful, False otherwise + """ + result = subprocess.run( + ["docker", "network", "connect", network_name, container_name], + capture_output=True, + text=True, + ) + return result.returncode == 0 + + +def get_container_status(container_id: str) -> str: + """Get the status of a Docker container. + + Args: + container_id: Docker container ID + + Returns: + Container status string (running, exited, etc.) + """ + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Status}}", container_id], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return result.stdout.strip() + return "unknown" + + +def get_container_logs(container_id: str, tail: int = 100) -> str: + """Get the logs of a Docker container. + + Args: + container_id: Docker container ID + tail: Number of lines to return + + Returns: + Container logs + """ + result = subprocess.run( + ["docker", "logs", "--tail", str(tail), container_id], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return result.stdout + return f"Failed to get logs: {result.stderr}" + + +def find_free_port(start: int = 10000, end: int = 20000) -> int: + """Find a free TCP port in the given range. + + Args: + start: Start of port range + end: End of port range + + Returns: + Free port number + """ + for port in range(start, end): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("localhost", port)) != 0: + return port + + raise RuntimeError(f"No free port found in range {start}-{end}") diff --git a/apps/api/src/services/docker/tunnel.py b/apps/api/src/services/docker/tunnel.py new file mode 100644 index 0000000..d53356b --- /dev/null +++ b/apps/api/src/services/docker/tunnel.py @@ -0,0 +1,146 @@ +"""Cloudflare tunnel management for Docker instances.""" + +import logging +import os +import re +import signal +import subprocess +import time +from typing import Any + +logger = logging.getLogger(__name__) + + +def start_cloudflared_tunnel( + container_name: str, port: int, timeout: int = 30 +) -> dict[str, str]: + """Start a temporary Cloudflare tunnel for a container. + + Uses 'cloudflared tunnel --url' to create a temporary tunnel + with a random trycloudflare.com URL. + + Args: + container_name: Name of the Docker container to tunnel to + port: Port number the container listens on + timeout: Maximum seconds to wait for tunnel URL + + Returns: + Dict with 'url' (the public tunnel URL) and 'pid' (process ID) + """ + import select as sel + + # First verify the container is accessible + logger.info("Checking connectivity to %s:%d...", container_name, port) + for attempt in range(10): + check = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + f"http://{container_name}:{port}"], + capture_output=True, + text=True, + timeout=5, + ) + logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()) + if check.returncode == 0: + break + time.sleep(1) + else: + logger.warning("Container %s:%d not responding to curl checks", container_name, port) + + # Run cloudflared in background, capture output + logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port) + proc = subprocess.Popen( + ["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + # Wait for the URL to appear in output + url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") + start_time = time.time() + url = None + + while time.time() - start_time < timeout: + # Read available output + readable, _, _ = sel.select([proc.stdout], [], [], 1.0) + if readable: + line = proc.stdout.readline() + if line: + match = url_pattern.search(line) + if match: + url = match.group(0) + break + + if not url: + proc.terminate() + proc.wait(timeout=5) + raise RuntimeError( + f"Failed to get tunnel URL within {timeout}s. " + f"cloudflared output may contain errors." + ) + + return {"url": url, "pid": str(proc.pid)} + + +def stop_cloudflared_tunnel(pid: str) -> None: + """Stop a cloudflared tunnel process. + + Args: + pid: Process ID of the cloudflared tunnel + """ + try: + os.kill(int(pid), signal.SIGTERM) + except ProcessLookupError: + pass # Already stopped + + +def recreate_tunnel( + container_name: str, port: int, old_pid: str | None = None +) -> dict[str, str]: + """Recreate a temporary Cloudflare tunnel. + + Stops the old tunnel (if pid provided) and starts a new one. + + Args: + container_name: Name of the Docker container to tunnel to + port: Port number the container listens on + old_pid: Optional PID of the old tunnel process to stop + + Returns: + Dict with 'url' and 'pid' for the new tunnel + """ + if old_pid: + stop_cloudflared_tunnel(old_pid) + + return start_cloudflared_tunnel(container_name, port) + + +def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: + """Check if a tunnel URL is healthy. + + Args: + url: The tunnel URL to check + timeout: Request timeout in seconds + + Returns: + Dict with 'healthy' (bool) and 'status_code' (int or None) + """ + try: + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", str(timeout), url], + capture_output=True, + text=True, + timeout=timeout + 5, + ) + status_code = int(result.stdout.strip()) + return { + "healthy": 200 <= status_code < 400, + "status_code": status_code, + } + except (ValueError, subprocess.TimeoutExpired, Exception) as e: + return { + "healthy": False, + "status_code": None, + "error": str(e), + } diff --git a/apps/api/src/services/docker_build.py b/apps/api/src/services/docker_build.py index 5f1356b..efd1eb8 100644 --- a/apps/api/src/services/docker_build.py +++ b/apps/api/src/services/docker_build.py @@ -6,9 +6,7 @@ import subprocess logger = logging.getLogger(__name__) -def build_image( - instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None -) -> tuple[int, str, str]: +def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]: """Build a Docker image from a Dockerfile. Args: @@ -20,18 +18,13 @@ def build_image( Returns: Tuple of (returncode, stdout, stderr) """ + import os from pathlib import Path - # Defensive: normalise any CRLF that may have crept in from manifest DB - # strings — Docker's legacy builder treats \r as a character after the - # backslash, breaking RUN continuations and producing - # "unknown instruction" errors. - dockerfile = dockerfile.replace("\r\n", "\n").replace("\r", "\n") - # Write Dockerfile dockerfile_path = Path(instance_dir) / "Dockerfile" - dockerfile_path.write_text(dockerfile, newline="\n") - logger.debug("Wrote Dockerfile to %s (%d bytes)", dockerfile_path, len(dockerfile)) + dockerfile_path.write_text(dockerfile) + logger.info("Wrote Dockerfile to %s", dockerfile_path) # Write build context files if build_context: @@ -41,27 +34,19 @@ def build_image( try: full_path.resolve().relative_to(Path(instance_dir).resolve()) except ValueError: - logger.error( - "Build context file path escapes instance directory: %s", file_path - ) - raise ValueError( - f"Build context file path '{file_path}' escapes instance directory" - ) - + logger.error("Build context file path escapes instance directory: %s", file_path) + raise ValueError(f"Build context file path '{file_path}' escapes instance directory") + full_path.parent.mkdir(parents=True, exist_ok=True) - normalized = content.replace("\r\n", "\n").replace("\r", "\n") - full_path.write_text(normalized, newline="\n") - logger.debug("Wrote build context file: %s", full_path) + full_path.write_text(content) + logger.info("Wrote build context file: %s", full_path) # Build image - logger.debug("Building Docker image with tag: %s", tag) + logger.info("Building Docker image with tag: %s", tag) cmd = [ - "docker", - "build", - "-t", - tag, - "-f", - str(dockerfile_path), + "docker", "build", + "-t", tag, + "-f", str(dockerfile_path), instance_dir, ] @@ -72,7 +57,7 @@ def build_image( text=True, timeout=300, # 5 minute timeout for builds ) - logger.debug("Docker build completed: returncode=%d", result.returncode) + logger.info("Docker build completed: returncode=%d", result.returncode) if result.returncode != 0: logger.error("Docker build failed: %s", result.stderr[:1000]) return result.returncode, result.stdout, result.stderr diff --git a/apps/api/src/services/git/__init__.py b/apps/api/src/services/git/__init__.py new file mode 100644 index 0000000..ed70d67 --- /dev/null +++ b/apps/api/src/services/git/__init__.py @@ -0,0 +1 @@ +"""Git services package.""" diff --git a/apps/api/src/services/git/control.py b/apps/api/src/services/git/control.py new file mode 100644 index 0000000..0cbb9c0 --- /dev/null +++ b/apps/api/src/services/git/control.py @@ -0,0 +1,196 @@ +"""Git control operations with repo validation.""" + +import logging +import os +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.git_repository import GitRepository +from src.models.user import User +from src.schemas.git_repository import ( + BranchCreateRequest, + CheckoutRequest, + CommitRequest, + FetchResponse, + MergeRequest, + MergeResponse, + PullResponse, + PushResponse, + StatusResponse, +) +from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate +from src.utils.git_control import ( + checkout_branch, + commit_changes, + create_branch, + delete_branch, + fetch, + get_status, + merge, + pull, + push, +) + +logger = logging.getLogger(__name__) + + +async def get_status_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> StatusResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + result = get_status(repo.path) + return StatusResponse( + branch=result.branch, + modified=result.modified, + added=result.added, + deleted=result.deleted, + untracked=result.untracked, + renamed=result.renamed, + ahead=result.ahead, + behind=result.behind, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def create_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: BranchCreateRequest, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + create_branch(repo.path, data.name, data.base_branch) + return {"message": f"Branch '{data.name}' created", "branch": data.name} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def delete_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch_name: str, + force: bool = False, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + delete_branch(repo.path, branch_name, force) + return {"message": f"Branch '{branch_name}' deleted"} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def checkout_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: CheckoutRequest, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + checkout_branch(repo.path, data.branch) + return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def commit_changes_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: CommitRequest, + user: User, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + author_name = user.name or "Unknown" + author_email = user.email or "unknown@example.com" + try: + commit_hash = commit_changes( + repo_path=repo.path, + message=data.message, + author_name=author_name, + author_email=author_email, + files=data.files, + ) + return {"commit_hash": commit_hash, "message": data.message} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def fetch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> FetchResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + fetch(repo.path) + return FetchResponse(message="Fetched from remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def pull_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str | None = None, +) -> PullResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + pull(repo.path, branch) + return PullResponse(message="Pulled from remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def push_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str | None = None, +) -> PushResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + push(repo.path, branch) + return PushResponse(message="Pushed to remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def merge_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: MergeRequest, +) -> MergeResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + commit_hash = merge( + repo_path=repo.path, + source_branch=data.source_branch, + target_branch=data.target_branch, + message=data.message, + ) + return MergeResponse( + commit_hash=commit_hash, + message=data.message or f"Merge {data.source_branch}", + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/services/git/files.py b/apps/api/src/services/git/files.py new file mode 100644 index 0000000..b1d2148 --- /dev/null +++ b/apps/api/src/services/git/files.py @@ -0,0 +1,150 @@ +"""Git file operations with repo validation.""" + +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.git_repository import GitRepository +from src.models.user import User +from src.schemas.git_repository import ( + FileContentResponse, + FileListResponse, + FileUpdateRequest, + FileUpdateResponse, +) +from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate +from src.utils.git_files import ( + commit_file, + get_file_content, + list_branches, + list_tree, +) + +logger = logging.getLogger(__name__) + + +async def list_files( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str = "main", + path: str = "", +) -> FileListResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + entries = list_tree(repo.path, branch=branch, path=path) + return FileListResponse( + path=path, + branch=branch, + entries=[ + { + "name": e.name, + "type": e.type, + "path": e.path, + "size": e.size, + "mode": e.mode, + "last_commit": e.last_commit, + } + for e in entries + ], + ) + except RuntimeError as e: + logger.error( + "Failed to list files for repo %s (path=%s, branch=%s): %s", + repo_id, + path, + branch, + str(e), + exc_info=True, + ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def get_file( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str, + path: str, +) -> FileContentResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + file_content = get_file_content(repo.path, branch=branch, path=path) + return FileContentResponse( + path=file_content.path, + branch=file_content.branch, + content=file_content.content, + size=file_content.size, + encoding=file_content.encoding, + language=file_content.language, + is_binary=file_content.is_binary, + last_commit=file_content.last_commit, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def update_file( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: FileUpdateRequest, + user: User, +) -> FileUpdateResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + author_name = user.name or "Unknown" + author_email = user.email or "unknown@example.com" + try: + commit_hash = commit_file( + repo_path=repo.path, + branch=data.branch, + path=data.path, + content=data.content, + commit_message=data.commit_message, + author_name=author_name, + author_email=author_email, + ) + return FileUpdateResponse( + commit_hash=commit_hash, + message=data.commit_message, + branch=data.branch, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def list_branches_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + branches, default_branch = list_branches(repo.path) + return { + "branches": [ + { + "name": b.name, + "is_default": b.is_default, + "last_commit": b.last_commit, + } + for b in branches + ], + "default_branch": default_branch, + } + except RuntimeError as e: + logger.error( + "Failed to list branches for repo %s: %s", + repo_id, + str(e), + exc_info=True, + ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/services/git/repository.py b/apps/api/src/services/git/repository.py new file mode 100644 index 0000000..d4b4bb0 --- /dev/null +++ b/apps/api/src/services/git/repository.py @@ -0,0 +1,211 @@ +"""Repository lifecycle and path helpers.""" + +import logging +import os +import shutil +import subprocess +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import Settings +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.user import User +from src.schemas.git_repository import GitRepositoryCreate +from src.utils.git_url_parser import parse_git_url + +logger = logging.getLogger(__name__) + + +def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: + """Generate the filesystem path for a repository.""" + base = Settings().repo_base_path or "/data/repos" + return os.path.join(base, str(user_id), str(project_id), f"{name}.git") + + +def _build_provider_clone_url(owner: str, repo: str) -> str: + """Build the SSH clone URL for the fixed git provider.""" + return f"git@git.commumedia.org:{owner}/{repo}.git" + + +def _preflight_remote_repository(remote_url: str) -> None: + """Verify a remote repository is reachable before cloning.""" + try: + result = subprocess.run( + ["git", "ls-remote", remote_url], + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository not found or inaccessible", + ) + + +def _clone_working_repository(remote_url: str, repo_path: str) -> None: + try: + result = subprocess.run( + ["git", "clone", remote_url, repo_path], + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to clone repository: {result.stderr}", + ) + + +def _init_working_repository(repo_path: str) -> None: + try: + result = subprocess.run( + ["git", "init", "-b", "main", repo_path], + capture_output=True, + text=True, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode == 0: + return + + fallback = subprocess.run( + ["git", "init", repo_path], + capture_output=True, + text=True, + ) + if fallback.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to initialize repository: {fallback.stderr}", + ) + + ref_result = subprocess.run( + ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"], + capture_output=True, + text=True, + ) + if ref_result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to set initial branch: {ref_result.stderr}", + ) + + +async def get_repo_and_validate( + session: AsyncSession, + repo_id: uuid.UUID, + project_id: uuid.UUID, +) -> GitRepository: + """Fetch a repository and validate ownership + disk presence.""" + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + return repo + + +def ensure_repo_on_disk(repo: GitRepository) -> None: + """Raise 404 if the repository is not present on disk.""" + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + +async def create_repository( + session: AsyncSession, + project_id: uuid.UUID, + data: GitRepositoryCreate, + user: User, +) -> GitRepository: + """Create a new git repository (clone or init).""" + # Check for duplicate name + existing = await session.execute( + select(GitRepository).where( + GitRepository.project_id == project_id, + GitRepository.name == data.name, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + + # Validate and potentially correct the URL + remote_url = data.remote_url + if remote_url and not data.force_original_url: + parse_result = parse_git_url(remote_url) + if parse_result["needs_parsing"] and parse_result["base_url"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": "The provided URL appears to be a browser URL, not a git clone URL", + "suggested_url": parse_result["base_url"], + "original_url": remote_url, + "error_code": "URL_NEEDS_PARSING", + }, + ) + if parse_result["base_url"]: + remote_url = parse_result["base_url"] + + if remote_url: + _preflight_remote_repository(remote_url) + + repo_path = _get_repo_path(user.id, project_id, data.name) + os.makedirs(os.path.dirname(repo_path), exist_ok=True) + + if remote_url: + _clone_working_repository(remote_url, repo_path) + else: + _init_working_repository(repo_path) + + repo = GitRepository( + name=data.name, + path=repo_path, + project_id=project_id, + owner_id=user.id, + is_mirror=False, + remote_url=remote_url, + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + return repo + + +async def delete_repository( + session: AsyncSession, + repo_id: uuid.UUID, + project_id: uuid.UUID, +) -> None: + """Delete a repository from DB and disk.""" + repo = await get_repo_and_validate(session, repo_id, project_id) + + if os.path.exists(repo.path): + shutil.rmtree(repo.path) + + await session.delete(repo) + await session.commit() + + +async def list_repositories( + session: AsyncSession, + project_id: uuid.UUID, +) -> list[GitRepository]: + """List all repositories in a project.""" + result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project_id) + ) + return list(result.scalars().all()) diff --git a/apps/api/src/services/instance_lifecycle.py b/apps/api/src/services/instance_lifecycle.py new file mode 100644 index 0000000..e28a51a --- /dev/null +++ b/apps/api/src/services/instance_lifecycle.py @@ -0,0 +1,420 @@ +"""High-level tool instance lifecycle orchestration. + +Coordinates Docker compose, container, tunnel, and config staging services +to create, start, stop, restart, and delete tool instances. +""" + +import logging +import os +import shutil +from datetime import datetime +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.config_folder import ConfigFolder +from src.models.config_profile import ConfigProfile +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.tool_config import ToolConfig +from src.models.tool_instance import ToolInstance +from src.models.tool_type import ToolType +from src.models.user import User +from src.services.docker import compose as compose_svc +from src.services.docker import config_staging +from src.services.docker import container as container_svc +from src.services.docker import tunnel as tunnel_svc +from src.services.docker_build import build_image +from src.services.readiness_probe import execute_probe + +logger = logging.getLogger(__name__) + + +async def create_new_instance( + session: AsyncSession, + project: Project, + repo: GitRepository, + tool_type: ToolType, + user: User, + display_name: str | None, + selected_profile: ConfigProfile | None, +) -> ToolInstance: + """Create a new tool instance record and its compose file.""" + instance_name = await compose_svc._generate_instance_name( + session, project.name, tool_type.name + ) + instance_dir = compose_svc.ensure_instance_directory(instance_name) + tool_port = container_svc.find_free_port() + + compose_path = await _build_or_render_compose( + tool_type, instance_name, instance_dir, repo, user, project.id, tool_port + ) + + instance = ToolInstance( + name=instance_name, + display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}", + tool_type_id=tool_type.id, + repository_id=repo.id, + project_id=project.id, + owner_id=user.id, + status="pending", + compose_path=compose_path, + port=tool_port, + selected_profile_id=selected_profile.id if selected_profile else None, + ) + session.add(instance) + await session.commit() + await session.refresh(instance) + return instance + + +async def start_existing_instance( + session: AsyncSession, + instance: ToolInstance, + user: User, + project_id: Any, +) -> dict: + """Start an existing instance: stage configs, compose up, probe, tunnel.""" + if not instance.compose_path or not os.path.exists(instance.compose_path): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found" + ) + + instance.status = "building" + await session.commit() + + env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( + session, user.id, instance.tool_type_id, project_id + ) + + selected_profile = None + if instance.selected_profile_id: + selected_profile = await session.get(ConfigProfile, instance.selected_profile_id) + if selected_profile and selected_profile.user_id == user.id: + instance_dir = os.path.dirname(instance.compose_path) + env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( + selected_profile, + instance_dir, + env_vars, + port_override, + start_command, + working_directory, + extra_volumes, + ) + + env_file_path, extra_volumes = await _stage_configs_and_folders( + session, user.id, project_id, os.path.dirname(instance.compose_path), + env_vars, config_files, extra_volumes + ) + + if port_override or start_command or working_directory or extra_volumes: + compose_svc._modify_compose_file( + instance.compose_path, port_override, start_command, working_directory, extra_volumes + ) + + returncode, _stdout, stderr = compose_svc.execute_compose_command( + instance.compose_path, "up", env_file=env_file_path + ) + if returncode != 0: + instance.status = "error" + await session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"failed to start instance: {stderr}", + ) + + container_id = container_svc.get_container_id(instance.name) + if container_id: + instance.container_id = container_id + container_name = container_svc.get_container_name(instance.name) + if container_name: + instance.container_name = container_name + container_svc.connect_container_to_network(container_name, "backend") + + instance.status = "starting" + instance.last_started_at = datetime.now() + await session.commit() + + tool_type = await session.get(ToolType, instance.tool_type_id) + success, probe_logs = await _run_readiness_probe(instance, tool_type) + if not success: + instance.status = "failed" + instance.url = None + instance.public_url = None + await session.commit() + return { + "status": "failed", + "error": f"Readiness probe failed: {' '.join(probe_logs)}", + } + + instance.status = "running" + await session.commit() + await _start_tunnel_if_web(instance, tool_type) + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +async def restart_existing_instance( + session: AsyncSession, + instance: ToolInstance, + user: User, + project_id: Any, +) -> dict: + """Restart an instance: re-stage configs, compose restart, tunnel.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop old tunnel: %s", exc) + + if not instance.compose_path or not os.path.exists(instance.compose_path): + instance.status = "error" + await session.commit() + return {"status": instance.status} + + env_vars, config_files, port_override, start_command, working_directory, _extra_env, extra_volumes = await _fetch_tool_configs( + session, user.id, instance.tool_type_id, project_id + ) + + stored_profile = None + if instance.selected_profile_id: + stored_profile = await session.get(ConfigProfile, instance.selected_profile_id) + if stored_profile and stored_profile.user_id == user.id: + instance_dir = os.path.dirname(instance.compose_path) + env_vars, port_override, start_command, working_directory, extra_volumes = await compose_svc._apply_resolved_profile( + stored_profile, instance_dir, env_vars, port_override, start_command, working_directory, extra_volumes + ) + + env_file_path, extra_volumes = await _stage_configs_and_folders( + session, user.id, project_id, os.path.dirname(instance.compose_path), + env_vars, config_files, extra_volumes + ) + + if port_override or start_command or working_directory or extra_volumes: + compose_svc._modify_compose_file( + instance.compose_path, port_override, start_command, working_directory, extra_volumes + ) + + returncode, _stdout, _stderr = compose_svc.execute_compose_command( + instance.compose_path, "restart", env_file=env_file_path + ) + if returncode != 0: + instance.status = "error" + await session.commit() + return {"status": instance.status} + + instance.status = "running" + instance.last_started_at = datetime.now() + + tool_type = await session.get(ToolType, instance.tool_type_id) + await _start_tunnel_if_web(instance, tool_type) + await session.commit() + + return {"status": instance.status, "url": instance.url} + + +async def stop_existing_instance(session: AsyncSession, instance: ToolInstance) -> None: + """Stop an instance and its tunnel.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop tunnel: %s", exc) + + if instance.compose_path and os.path.exists(instance.compose_path): + compose_svc.execute_compose_command(instance.compose_path, "stop") + + instance.status = "stopped" + instance.last_stopped_at = datetime.now() + instance.url = None + instance.public_url = None + instance.tunnel_id = None + await session.commit() + + +async def delete_existing_instance(session: AsyncSession, instance: ToolInstance) -> None: + """Delete an instance, its containers, and its directory.""" + if instance.tunnel_id: + try: + tunnel_svc.stop_cloudflared_tunnel(instance.tunnel_id) + except Exception as exc: + logger.warning("Failed to stop tunnel: %s", exc) + + if instance.compose_path and os.path.exists(instance.compose_path): + compose_svc.execute_compose_command(instance.compose_path, "down") + instance_dir = os.path.dirname(instance.compose_path) + if os.path.exists(instance_dir): + shutil.rmtree(instance_dir) + + await session.delete(instance) + await session.commit() + + +# ── Internal helpers ─────────────────────────────────────────────────────── + +async def _build_or_render_compose( + tool_type: ToolType, + instance_name: str, + instance_dir: str, + repo: GitRepository, + user: User, + project_id: Any, + tool_port: int, +) -> str: + """Build Dockerfile or render compose template.""" + if tool_type.definition_type == "dockerfile": + image_tag = f"headquarter/{instance_name}:latest" + if tool_type.dockerfile_template: + returncode, _stdout, stderr = build_image( + instance_dir=instance_dir, + dockerfile=tool_type.dockerfile_template, + tag=image_tag, + build_context=tool_type.build_context, + ) + if returncode != 0: + logger.error("Build failed for %s: %s", instance_name, stderr) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to build Docker image: {stderr[:500]}", + ) + + compose_content = ( + f'version: "3.8"\nservices:\n app:\n' + f' image: {image_tag}\n' + f' container_name: {instance_name}\n' + f' ports:\n - "{tool_port}:{tool_type.default_port}"\n' + f' volumes:\n - {repo.path}:/workspace\n' + f' restart: unless-stopped\n' + ) + else: + variables = { + "REPO_PATH": repo.path, + "INSTANCE_NAME": instance_name, + "INSTANCE_ID": instance_name, + "TOOL_NAME": instance_name, + "TOOL_PORT": tool_port, + "USER_ID": str(user.id), + "PROJECT_ID": str(project_id), + } + compose_content = compose_svc.render_compose_template( + tool_type.compose_template, variables + ) + + compose_svc.write_compose_file(instance_dir, compose_content) + return os.path.join(instance_dir, "docker-compose.yml") + + +async def _fetch_tool_configs( + session: AsyncSession, + user_id: Any, + tool_type_id: Any, + project_id: Any, +) -> tuple[dict, dict, Any, Any, Any, dict, list]: + """Fetch tool configs and return parsed values.""" + env_vars: dict[str, str] = {} + config_files: dict[str, str] = {} + port_override = None + start_command = None + working_directory = None + extra_env_vars: dict[str, str] = {} + extra_volumes: list[dict] = [] + + query = ( + select(ToolConfig) + .where(ToolConfig.user_id == user_id, ToolConfig.tool_type_id == tool_type_id) + .where((ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))) + ) + configs = (await session.execute(query)).scalars().all() + + for cfg in configs: + if cfg.config_type == "env": + env_vars[cfg.key] = cfg.value + elif cfg.config_type == "file" and cfg.file_path: + config_files[cfg.file_path] = cfg.value + if cfg.port_override: + port_override = cfg.port_override + if cfg.start_command: + start_command = cfg.start_command + if cfg.working_directory: + working_directory = cfg.working_directory + if cfg.environment_variables: + extra_env_vars.update(cfg.environment_variables) + if cfg.volumes: + extra_volumes.extend(cfg.volumes) + + env_vars.update(extra_env_vars) + return env_vars, config_files, port_override, start_command, working_directory, extra_env_vars, extra_volumes + + +async def _stage_configs_and_folders( + session: AsyncSession, + user_id: Any, + project_id: Any, + instance_dir: str, + env_vars: dict[str, str], + config_files: dict[str, str], + extra_volumes: list[dict], +) -> tuple[str | None, list[dict]]: + """Write env/config files and config folders.""" + env_file_path: str | None = None + if env_vars: + env_file_path = compose_svc.write_env_file(instance_dir, env_vars) + if config_files: + config_staging.write_config_files(instance_dir, config_files) + + folder_query = select(ConfigFolder).where( + ConfigFolder.user_id == user_id, ConfigFolder.is_active.is_(True) + ) + folders = (await session.execute(folder_query)).scalars().all() + if folders: + folder_volumes = config_staging.write_config_folder_files( + instance_dir, folders, str(project_id) + ) + extra_volumes.extend(folder_volumes) + + return env_file_path, extra_volumes + + +async def _start_tunnel_if_web(instance: ToolInstance, tool_type: ToolType) -> None: + """Create Cloudflare tunnel for web-enabled tools.""" + if "web" not in tool_type.interfaces or not tool_type.default_port: + instance.url = None + instance.public_url = None + return + + try: + tunnel_info = tunnel_svc.start_cloudflared_tunnel( + container_name=instance.container_name or instance.name, + port=tool_type.default_port, + ) + instance.tunnel_id = tunnel_info["pid"] + instance.public_url = tunnel_info["url"] + instance.url = tunnel_info["url"] + logger.info("Created tunnel for instance %s: %s", instance.id, tunnel_info["url"]) + except Exception as exc: + logger.error("Failed to create tunnel for instance %s: %s", instance.id, exc) + instance.status = "error" + instance.url = None + + +async def _run_readiness_probe( + instance: ToolInstance, tool_type: ToolType +) -> tuple[bool, list[str]]: + """Run readiness probe if configured.""" + if not tool_type.readiness_probe or not instance.container_id: + return True, [] + + probe = tool_type.readiness_probe + command = probe.get("command", "") + if not command: + return True, [] + + return await execute_probe( + container_id=instance.container_id, + command=command, + timeout=probe.get("timeout", 30), + interval=probe.get("interval", 2), + ) diff --git a/apps/api/src/services/profile_resolver.py b/apps/api/src/services/profile_resolver.py new file mode 100644 index 0000000..345497c --- /dev/null +++ b/apps/api/src/services/profile_resolver.py @@ -0,0 +1,251 @@ +"""Profile resolver service for recursive ordered include resolution. + +Provides deterministic merge rules, save-independent cycle protection, +and resolved output structures for env vars, runtime hints, mounts, +file trees, and override metadata. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile + + +@dataclass +class ResolvedMount: + """A resolved mount with merged file tree and final mode.""" + + target_path: str + mode: str # "ro" or "rw" + files: dict[str, str] = field(default_factory=dict) + """Relative file paths to UTF-8 text content.""" + overridden_files: dict[str, list[str]] = field(default_factory=dict) + """Map of relative file path to list of profile names that contributed + (latest is the winner).""" + mode_overridden_by: str | None = None + """Name of the profile that set the final mode, if different from first.""" + + +@dataclass +class ResolvedRuntimeHints: + """Resolved runtime hints from profile layers.""" + + start_command: str | None = None + working_directory: str | None = None + port: int | None = None + overridden_hints: dict[str, str] = field(default_factory=dict) + """Map of hint key to profile name that provided the winning value.""" + + +@dataclass +class ResolvedProfileOutput: + """Complete resolved output for a config profile.""" + + profile_id: uuid.UUID + profile_name: str + environment_variables: dict[str, str] = field(default_factory=dict) + """Final merged env vars (later layers win).""" + env_var_sources: dict[str, list[str]] = field(default_factory=dict) + """Map of env var key to ordered list of contributing profile names + (latest is the winner).""" + runtime_hints: ResolvedRuntimeHints = field( + default_factory=lambda: ResolvedRuntimeHints() + ) + mounts: dict[str, ResolvedMount] = field(default_factory=dict) + """Map of target_path to ResolvedMount.""" + resolution_order: list[str] = field(default_factory=list) + """Ordered list of profile names as they were resolved.""" + cycle_detected: bool = False + cycle_path: list[str] | None = None + + +class ProfileResolutionError(Exception): + """Raised when profile resolution fails.""" + + pass + + +class ProfileCycleError(ProfileResolutionError): + """Raised when a cycle is detected during profile resolution.""" + + def __init__(self, cycle_path: list[str]) -> None: + self.cycle_path = cycle_path + path_str = " -> ".join(cycle_path) + super().__init__(f"Profile include cycle detected: {path_str}") + + +def _merge_env_vars( + current: dict[str, str], + sources: dict[str, list[str]], + profile: ConfigProfile, +) -> None: + """Merge a profile's env vars into the current dict, tracking sources.""" + if not profile.environment_variables: + return + for key, value in profile.environment_variables.items(): + current[key] = value + if key not in sources: + sources[key] = [] + sources[key].append(profile.name) + + +def _merge_runtime_hints( + hints: ResolvedRuntimeHints, + profile: ConfigProfile, +) -> None: + """Merge a profile's runtime hints, tracking overrides.""" + if profile.start_command is not None: + hints.start_command = profile.start_command + hints.overridden_hints["start_command"] = profile.name + if profile.working_directory is not None: + hints.working_directory = profile.working_directory + hints.overridden_hints["working_directory"] = profile.name + if profile.port is not None: + hints.port = profile.port + hints.overridden_hints["port"] = profile.name + + +def _merge_mounts( + mounts: dict[str, ResolvedMount], + profile_mounts: list[ConfigMount], + profile: ConfigProfile, +) -> None: + """Merge a profile's mounts into the current mounts dict.""" + for mount in profile_mounts: + target = mount.target_path + if target not in mounts: + mounts[target] = ResolvedMount( + target_path=target, + mode=mount.mode, + files={}, + overridden_files={}, + ) + resolved = mounts[target] + + # Mode override: later wins + if resolved.mode != mount.mode: + resolved.mode = mount.mode + resolved.mode_overridden_by = profile.name + + # File tree merge: later wins for same relative path + if mount.files: + for rel_path, content in mount.files.items(): + if rel_path not in resolved.files: + resolved.overridden_files[rel_path] = [] + else: + if rel_path not in resolved.overridden_files: + resolved.overridden_files[rel_path] = [] + resolved.overridden_files[rel_path].append(profile.name) + resolved.files[rel_path] = content + + +def _resolve_profile_recursive( + profile: ConfigProfile, + visited: set[uuid.UUID], + path: list[str], + resolution_order: list[str], + env_vars: dict[str, str], + env_var_sources: dict[str, list[str]], + runtime_hints: ResolvedRuntimeHints, + mounts: dict[str, ResolvedMount], +) -> None: + """Recursively resolve a profile and its includes. + + Args: + profile: The profile to resolve + visited: Set of already-resolved profile IDs to avoid duplicates + path: Current recursion path for cycle detection + resolution_order: Ordered list of profile names being resolved + env_vars: Accumulated environment variables + env_var_sources: Tracking of which profiles contributed each env var + runtime_hints: Accumulated runtime hints + mounts: Accumulated mounts + + Raises: + ProfileCycleError: If a cycle is detected + """ + if profile.name in path: + # Cycle detected + cycle_start = path.index(profile.name) + cycle_path = path[cycle_start:] + [profile.name] + raise ProfileCycleError(cycle_path) + + if profile.id in visited: + # Already resolved in another branch (diamond graph) + return + + visited.add(profile.id) + path.append(profile.name) + resolution_order.append(profile.name) + + # Resolve includes first (in order) + includes: list[ConfigInclude] = list(profile.includes) + includes.sort(key=lambda inc: inc.order_index) + for include in includes: + included_profile = include.included_profile + if included_profile is not None: + _resolve_profile_recursive( + included_profile, + visited, + path, + resolution_order, + env_vars, + env_var_sources, + runtime_hints, + mounts, + ) + + # Apply this profile's values (later layers win) + _merge_env_vars(env_vars, env_var_sources, profile) + _merge_runtime_hints(runtime_hints, profile) + _merge_mounts(mounts, list(profile.mounts), profile) + + path.pop() + + +def resolve_profile(profile: ConfigProfile) -> ResolvedProfileOutput: + """Resolve a config profile with all its includes. + + Processes included profiles in configured order, then applies the + selected profile itself. Later layers override earlier layers. + + Args: + profile: The root profile to resolve + + Returns: + ResolvedProfileOutput with merged env vars, runtime hints, mounts, + and override metadata + + Raises: + ProfileCycleError: If a cycle is detected in the include graph + """ + env_vars: dict[str, str] = {} + env_var_sources: dict[str, list[str]] = {} + runtime_hints = ResolvedRuntimeHints() + mounts: dict[str, ResolvedMount] = {} + resolution_order: list[str] = [] + + _resolve_profile_recursive( + profile, + set(), + [], + resolution_order, + env_vars, + env_var_sources, + runtime_hints, + mounts, + ) + + return ResolvedProfileOutput( + profile_id=profile.id, + profile_name=profile.name, + environment_variables=env_vars, + env_var_sources=env_var_sources, + runtime_hints=runtime_hints, + mounts=mounts, + resolution_order=resolution_order, + ) diff --git a/apps/api/src/services/terminal_manager.py b/apps/api/src/services/terminal_manager.py index b9605db..6cb8f73 100644 --- a/apps/api/src/services/terminal_manager.py +++ b/apps/api/src/services/terminal_manager.py @@ -1,426 +1,193 @@ """Terminal session manager for WebSocket connections.""" import asyncio +import contextlib +import json import logging +import time import uuid -from datetime import datetime, timezone +from collections.abc import Coroutine +from typing import Any from fastapi import WebSocket -from sqlalchemy.dialects.postgresql import insert as pg_insert -from src.database import SessionLocal -from src.models.terminal_session import TerminalSessionModel from src.services.terminal_session import TerminalSession logger = logging.getLogger(__name__) - -class MaxSessionsExceededError(Exception): - """Raised when the maximum number of terminal sessions per instance is reached.""" - - def __init__(self, instance_id: str, max_sessions: int = 5) -> None: - self.instance_id = instance_id - self.max_sessions = max_sessions - super().__init__( - f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}" - ) +_READ_BATCH_INTERVAL_S = 0.016 # 16ms max batching delay +_READ_POLL_TIMEOUT_S = 0.005 +_READ_POLL_SLEEP_S = 0.001 +_HEARTBEAT_INTERVAL_S = 15.0 +_IDLE_TIMEOUT_S = 60.0 class TerminalManager: - """Manages active terminal sessions with persistence support.""" - - # Maximum sessions per tool instance - MAX_SESSIONS_PER_INSTANCE = 5 + """Manages active terminal sessions.""" def __init__(self) -> None: - # Track sessions by (instance_id, session_id) for multi-session support - self._sessions: dict[tuple[str, str], TerminalSession] = {} - self._idle_check_task: asyncio.Task | None = None - self._start_idle_check() - - def _start_idle_check(self) -> None: - """Start the idle timeout background task.""" - if self._idle_check_task is not None and not self._idle_check_task.done(): - return - try: - loop = asyncio.get_running_loop() - self._idle_check_task = loop.create_task(self._idle_check_loop()) - except RuntimeError: - # No event loop running yet, will be started lazily - pass - - async def _idle_check_loop(self) -> None: - """Periodically check for idle sessions and clean them up.""" - while True: - try: - await asyncio.sleep(60) # Check every minute - await self._cleanup_idle_sessions() - except Exception as exc: - logger.error("Error in idle check loop: %s", exc) - - async def _cleanup_idle_sessions(self) -> None: - """Clean up sessions that have been idle for too long.""" - idle_keys = [] - for (instance_id, session_id), session in list(self._sessions.items()): - if session.is_idle(): - idle_keys.append((instance_id, session_id)) - - for key in idle_keys: - instance_id, session_id = key - logger.info( - "Cleaning up idle terminal session %s for instance %s", - session_id, - instance_id, - ) - session = self._sessions.pop(key, None) - if session: - await session.close() - # Update DB status fire-and-forget - asyncio.create_task(self._mark_closed_in_db(session_id)) - - async def _insert_db_session_row( - self, - session_id: str, - instance_id: uuid.UUID, - name: str, - ) -> None: - """Insert a TerminalSessionModel row into the database. - - Uses ON CONFLICT DO NOTHING to handle races when a session is - restored from DB and then re-inserted. - """ - try: - async with SessionLocal() as db_session: - stmt = ( - pg_insert(TerminalSessionModel) - .values( - id=uuid.UUID(session_id), - instance_id=instance_id, - name=name, - status="active", - created_at=datetime.now(timezone.utc), - last_activity_at=datetime.now(timezone.utc), - ) - .on_conflict_do_nothing(index_elements=["id"]) - ) - await db_session.execute(stmt) - await db_session.commit() - logger.debug( - "Inserted terminal session row %s for instance %s", - session_id, - instance_id, - ) - except Exception as exc: - logger.error("Failed to insert terminal session row: %s", exc) - - async def _mark_closed_in_db(self, session_id: str) -> None: - """Mark a terminal session as closed in the database.""" - try: - async with SessionLocal() as db_session: - db_row = await db_session.get( - TerminalSessionModel, uuid.UUID(session_id) - ) - if db_row: - db_row.status = "closed" - db_row.closed_at = datetime.now(timezone.utc) - await db_session.commit() - logger.debug( - "Marked terminal session %s as closed in DB", session_id - ) - except Exception as exc: - logger.error("Failed to mark terminal session as closed in DB: %s", exc) - - def _count_sessions_for_instance(self, instance_id_str: str) -> int: - """Count active in-memory sessions for a given instance.""" - return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str) + """Initialise the terminal manager.""" + self._sessions: dict[str, TerminalSession] = {} + self._last_client_message: dict[str, float] = {} + self._background_tasks: set[asyncio.Task[Any]] = set() async def create_session( self, instance_id: uuid.UUID, container_id: str, - startup_command: str | None = None, - name: str | None = None, - session_id: str | None = None, + websocket: WebSocket, ) -> TerminalSession: - """Create a new terminal session for an instance. - - Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance. - Inserts a DB row fire-and-forget. - - Args: - instance_id: UUID of the tool instance. - container_id: Docker container ID. - startup_command: Optional startup command to run. - name: Optional session name (auto-generated if omitted). - - Returns: - The newly created TerminalSession. - - Raises: - MaxSessionsExceededError: If the instance already has max sessions. - """ - instance_id_str = str(instance_id) - - if ( - self._count_sessions_for_instance(instance_id_str) - >= self.MAX_SESSIONS_PER_INSTANCE - ): - raise MaxSessionsExceededError( - instance_id_str, self.MAX_SESSIONS_PER_INSTANCE - ) - - if session_id is None: - session_id = str(uuid.uuid4()) - session = TerminalSession( - session_id=session_id, - instance_id=instance_id, - container_id=container_id, - startup_command=startup_command, - name=name, - ) - await session.start(startup_command=startup_command) - - key = (instance_id_str, session_id) - self._sessions[key] = session - - # Fire-and-forget DB insert (skip if row already exists) - asyncio.create_task( - self._insert_db_session_row(session_id, instance_id, session.name) - ) - - logger.info( - "Created terminal session %s for instance %s (name=%s)", - session_id, - instance_id, - session.name, - ) - return session - - async def get_or_create_session( - self, - instance_id: uuid.UUID, - container_id: str, - startup_command: str | None = None, - ) -> TerminalSession: - """Get existing session or create a new one. - - Backward-compatible alias that uses 'default' as the session_id. - """ - # Ensure idle check is running (lazy start) - self._start_idle_check() - - instance_id_str = str(instance_id) - key = (instance_id_str, "default") - - # Check for existing default session - if key in self._sessions: - session = self._sessions[key] - - # Check if session is still alive - if session.is_alive(): - logger.debug( - "Reattaching to existing terminal session for instance %s", - instance_id, - ) - return session - else: - # Session died, clean it up - logger.debug( - "Existing session for instance %s is dead, cleaning up", - instance_id, - ) - await session.close() - del self._sessions[key] - - # Create new default session - logger.info( - "Creating new default terminal session for instance %s", instance_id - ) + """Create a new terminal session.""" session_id = str(uuid.uuid4()) - session = TerminalSession( - session_id=session_id, - instance_id=instance_id, - container_id=container_id, - startup_command=startup_command, - name="Session 1", - ) - await session.start(startup_command=startup_command) - self._sessions[key] = session + session = TerminalSession(session_id, instance_id, container_id) + await session.start() + self._sessions[session_id] = session + self._last_client_message[session_id] = time.monotonic() - # Fire-and-forget DB insert - asyncio.create_task( - self._insert_db_session_row(session_id, instance_id, session.name) - ) + # Start background tasks for I/O streaming + self._start_task(self._read_loop(session, websocket)) + self._start_task(self._write_loop(session, websocket)) + self._start_task(self._heartbeat_loop(session, websocket)) return session - def get_session( - self, - instance_id: str, - session_id: str, - ) -> TerminalSession | None: - """Lookup a session by composite key, or by internal session_id.""" - session = self._sessions.get((instance_id, session_id)) - if session is not None: - return session - # Fallback: search by internal TerminalSession.session_id - for (iid, _sid), sess in self._sessions.items(): - if iid == instance_id and sess.session_id == session_id: - return sess - return None + def _start_task(self, coro: Coroutine[Any, Any, None]) -> None: + """Start a background task and store a reference to prevent GC.""" + task = asyncio.create_task(coro) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) - def _find_key_by_internal_id( - self, - instance_id: str, - internal_session_id: str, - ) -> tuple[str, str] | None: - """Find the manager dict key for a session by its internal session_id.""" - for (iid, sid), session in self._sessions.items(): - if iid == instance_id and session.session_id == internal_session_id: - return (iid, sid) - return None - - def get_sessions_for_instance( - self, - instance_id: str, - ) -> list[TerminalSession]: - """Return all in-memory sessions for a given instance.""" - return [ - session - for (iid, _sid), session in self._sessions.items() - if iid == instance_id - ] - - async def close_session( - self, - instance_id: str, - session_id: str, - ) -> None: - """Close a specific session and update its DB status.""" - key = (instance_id, session_id) - session = self._sessions.pop(key, None) - if session: - await session.close() - # Fire-and-forget DB update - asyncio.create_task(self._mark_closed_in_db(session_id)) - logger.info( - "Closed terminal session %s for instance %s", - session_id, - instance_id, - ) - - async def attach_websocket( + async def _read_loop( self, session: TerminalSession, websocket: WebSocket, ) -> None: - """Attach a WebSocket to an existing session. + """Read output from the container and send to WebSocket with batching.""" + try: + buffer = bytearray() + last_flush = time.monotonic() - Closes existing WebSocket connections only for this specific session. - """ - # Handle concurrent connections - close existing ones within the same session - if session.has_websockets(): - logger.debug( - "Closing existing WebSocket connections for session %s (instance %s)", + while session.is_alive() and not session.closed: + data = await session.read_output(select_timeout=_READ_POLL_TIMEOUT_S) + if data: + buffer.extend(data) + + now = time.monotonic() + flush_due = buffer and ( + now - last_flush >= _READ_BATCH_INTERVAL_S or not data + ) + + if flush_due: + await websocket.send_bytes(bytes(buffer)) + buffer.clear() + last_flush = now + elif not data: + await asyncio.sleep(_READ_POLL_SLEEP_S) + + # Flush any remaining data + if buffer: + with contextlib.suppress(Exception): + await websocket.send_bytes(bytes(buffer)) + + except Exception: + logger.exception("Read loop error for session %s", session.session_id) + finally: + await self._cleanup_session(session) + + async def _write_loop( + self, + session: TerminalSession, + websocket: WebSocket, + ) -> None: + """Read input from WebSocket and send to container.""" + try: + while session.is_alive() and not session.closed: + message = await websocket.receive() + self._last_client_message[session.session_id] = time.monotonic() + + if message["type"] == "websocket.receive": + if "bytes" in message: + await session.write_input(message["bytes"]) + elif "text" in message: + text = message["text"] + if text.startswith("{"): + try: + ctrl = json.loads(text) + await self._handle_control_message( + session, + websocket, + ctrl, + ) + except json.JSONDecodeError: + logger.debug("Invalid JSON control message: %s", text) + else: + await session.write_input(text.encode("utf-8")) + elif message["type"] == "websocket.disconnect": + break + except Exception: + logger.exception("Write loop error for session %s", session.session_id) + finally: + await self._cleanup_session(session) + + async def _handle_control_message( + self, + session: TerminalSession, + websocket: WebSocket, + ctrl: dict[str, Any], + ) -> None: + """Handle a JSON control message from the client.""" + msg_type = ctrl.get("type") + if msg_type == "resize": + await session.resize( + ctrl.get("cols", 80), + ctrl.get("rows", 24), + ) + elif msg_type == "ping": + await websocket.send_json( + {"type": "pong", "id": ctrl.get("id")}, + ) + + async def _heartbeat_loop( + self, + session: TerminalSession, + websocket: WebSocket, + ) -> None: + """Monitor client activity and close idle connections.""" + try: + while session.is_alive() and not session.closed: + await asyncio.sleep(_HEARTBEAT_INTERVAL_S) + last_msg = self._last_client_message.get(session.session_id, 0) + if time.monotonic() - last_msg > _IDLE_TIMEOUT_S: + # Client has been silent for 60s — close connection + with contextlib.suppress(Exception): + await websocket.close( + code=1000, + reason="Idle timeout", + ) + break + except Exception: + logger.exception( + "Heartbeat loop error for session %s", session.session_id, - session.instance_id, ) - for ws in list(session._websockets): - try: - await ws.close(code=4000, reason="New connection established") - except Exception: - pass # noqa: S110 - session._websockets.clear() + finally: + await self._cleanup_session(session) - # Attach new WebSocket - session.attach_websocket(websocket) - - # Replay buffer - buffer = session.get_buffer() - if buffer: - try: - await websocket.send_bytes(buffer) - except Exception: - pass # noqa: S110 - - async def detach_websocket( - self, - session: TerminalSession, - websocket: WebSocket, - ) -> None: - """Detach a WebSocket from a session.""" - session.detach_websocket(websocket) - - async def reset_session( - self, - instance_id: uuid.UUID, - container_id: str, - startup_command: str | None = None, - session_id: str | None = None, - name: str | None = None, - ) -> TerminalSession: - """Reset a session by killing it and creating a new one. - - Args: - instance_id: UUID of the tool instance. - container_id: Docker container ID. - startup_command: Optional startup command. - session_id: Specific session to reset. If None, resets the default session. - name: Optional name to preserve for the new session. - - Returns: - The newly created TerminalSession. - """ - instance_id_str = str(instance_id) - target_session_id = session_id or "default" - key = (instance_id_str, target_session_id) - - # Preserve old name if not provided - old_name = name - if old_name is None and key in self._sessions: - old_name = self._sessions[key].name - - # Close existing session if any - if key in self._sessions: - logger.debug( - "Resetting terminal session %s for instance %s", - target_session_id, - instance_id, - ) - old_session = self._sessions.pop(key) - await old_session.close() - # Fire-and-forget DB update for old session - asyncio.create_task(self._mark_closed_in_db(old_session.session_id)) - - # Create new session preserving the same session_id slot - new_session_id = str(uuid.uuid4()) - new_session = TerminalSession( - session_id=new_session_id, - instance_id=instance_id, - container_id=container_id, - startup_command=startup_command, - name=old_name or ("Session 1" if target_session_id == "default" else None), - ) - await new_session.start(startup_command=startup_command) - self._sessions[key] = new_session - - # Fire-and-forget DB insert - asyncio.create_task( - self._insert_db_session_row(new_session_id, instance_id, new_session.name) - ) - - return new_session + async def _cleanup_session(self, session: TerminalSession) -> None: + """Clean up a session.""" + if session.session_id in self._sessions: + del self._sessions[session.session_id] + self._last_client_message.pop(session.session_id, None) + await session.close() async def close_all(self) -> None: """Close all active sessions.""" sessions = list(self._sessions.values()) self._sessions.clear() + self._last_client_message.clear() for session in sessions: await session.close() - if self._idle_check_task and not self._idle_check_task.done(): - self._idle_check_task.cancel() - # Global terminal manager instance terminal_manager = TerminalManager() diff --git a/apps/api/src/services/terminal_session.py b/apps/api/src/services/terminal_session.py index dff40cd..7f3a490 100644 --- a/apps/api/src/services/terminal_session.py +++ b/apps/api/src/services/terminal_session.py @@ -1,136 +1,44 @@ -"""High-performance terminal session with asyncio-native I/O. - -Replaces blocking select.select() with event-driven asyncio.add_reader() -for sub-frame latency. Includes output batching and flow control. -""" +"""Terminal session management for tool instances.""" import asyncio +import contextlib +import fcntl import logging import os import pty -import signal +import select import struct -import fcntl -import time +import termios import uuid -from collections import deque -from typing import Any logger = logging.getLogger(__name__) class TerminalSession: - """Manages a single terminal session with event-driven PTY I/O. - - Uses asyncio.add_reader() instead of polling for near-zero read latency. - Output is batched (2ms window) and sent as binary WebSocket frames. - Flow control prevents memory bloat on fast output. - """ - - # Circular buffer for replay (10KB) - BUFFER_SIZE = 10 * 1024 - - # Idle timeout in seconds (30 minutes) - IDLE_TIMEOUT = 30 * 60 - - # Output batching window in seconds - BATCH_WINDOW_S = 0.002 # 2ms - - # Flow control: pause PTY reads when unacknowledged bytes exceed this - FLOW_CONTROL_PAUSE = 64 * 1024 - - # Flow control: resume PTY reads when unacknowledged bytes drop below this - FLOW_CONTROL_RESUME = 32 * 1024 - - # Max WebSocket frame size - MAX_FRAME_SIZE = 64 * 1024 - - # Session number counter per instance_id for auto-naming - _instance_counters: dict[str, int] = {} + """Manages a single terminal session connected to a docker container.""" def __init__( self, session_id: str, instance_id: uuid.UUID, container_id: str, - startup_command: str | None = None, - name: str | None = None, ) -> None: + """Initialize a terminal session.""" self.session_id = session_id self.instance_id = instance_id self.container_id = container_id - self.startup_command = startup_command self.process: asyncio.subprocess.Process | None = None self._closed = False self._master_fd: int | None = None + self._slave_fd: int | None = None + self._echo_enabled = True + self._exit_reason: str | None = None - # Circular buffer for output replay - self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE) - self._buffer_size = 0 - - # WebSocket connections - self._websockets: set[Any] = set() - - # Activity tracking - self.last_activity = time.time() - - # Terminal size - self._cols = 80 - self._rows = 24 - - # Session metadata - self.name = name or self._generate_name(str(instance_id)) - self.status: str = "active" - - # Output batching - self._batch_buffer = bytearray() - self._batch_timer: asyncio.TimerHandle | None = None - self._batch_lock = asyncio.Lock() - - # Flow control - self._unacknowledged_bytes = 0 - self._paused = False - self._read_handler_set = False - self._flow_control_lock = asyncio.Lock() - - # Ack timeout fallback - self._ack_timeout_handle: asyncio.TimerHandle | None = None - - @classmethod - def _generate_name(cls, instance_id: str) -> str: - """Generate an auto-incremented session name for the instance.""" - count = cls._instance_counters.get(instance_id, 0) + 1 - cls._instance_counters[instance_id] = count - return f"Session {count}" - - async def start(self, startup_command: str | None = None) -> None: + async def start(self) -> None: """Start the docker exec process with a shell using a PTY.""" - # Create a pseudo-terminal on the host - self._master_fd, slave_fd = pty.openpty() + self._master_fd, self._slave_fd = pty.openpty() + self._set_terminal_size(80, 24) - # Set the terminal size initially - self._set_terminal_size(self._cols, self._rows) - logger.debug( - "Starting terminal session %s for container %s with initial size %sx%s", - self.session_id, - self.container_id, - self._cols, - self._rows, - ) - - # Build the shell command - cmd = startup_command or self.startup_command - if cmd: - shell_cmd = f'bash -c "{cmd}" || true; exec bash -il' - logger.debug( - "Using startup command for session %s: %s", - self.session_id, - cmd, - ) - else: - shell_cmd = "bash -il" - - # Start docker exec with the slave fd as stdin/stdout/stderr self.process = await asyncio.create_subprocess_exec( "docker", "exec", @@ -139,287 +47,112 @@ class TerminalSession: "TERM=xterm-256color", self.container_id, "bash", - "-c", - shell_cmd, - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, + "-il", + stdin=self._slave_fd, + stdout=self._slave_fd, + stderr=self._slave_fd, ) - # Close slave fd in parent process - os.close(slave_fd) + os.close(self._slave_fd) + self._slave_fd = None + self._echo_enabled = self._detect_echo_state() - self.last_activity = time.time() - - # Start event-driven reading - self._start_reading() - - def _start_reading(self) -> None: - """Register PTY master fd with asyncio event loop for event-driven reads.""" - if self._read_handler_set or self._master_fd is None or self._closed: + def _set_terminal_size(self, cols: int, rows: int) -> None: + """Set the terminal size using TIOCSWINSZ.""" + if self._master_fd is None: return - try: - loop = asyncio.get_event_loop() - loop.add_reader(self._master_fd, self._on_fd_readable) - self._read_handler_set = True - logger.debug("Started event-driven reading for session %s", self.session_id) - except Exception as exc: - logger.error( - "Failed to start reading for session %s: %s", self.session_id, exc - ) + tiocswinsz = 0x5414 + size = struct.pack("HHHH", rows, cols, 0, 0) + with contextlib.suppress(OSError): + fcntl.ioctl(self._master_fd, tiocswinsz, size) - def _stop_reading(self) -> None: - """Unregister PTY master fd from asyncio event loop.""" - if not self._read_handler_set or self._master_fd is None: - return + def _detect_echo_state(self) -> bool: + """Detect whether the PTY has echo enabled via termios.""" + if self._master_fd is None: + return True try: - loop = asyncio.get_event_loop() - loop.remove_reader(self._master_fd) - self._read_handler_set = False - except Exception: - pass + attrs = termios.tcgetattr(self._master_fd) + return bool(attrs[3] & termios.ECHO) + except OSError: + return True - def _on_fd_readable(self) -> None: - """Callback when PTY master fd has data available (called by event loop).""" + async def check_echo_state(self) -> bool | None: + """Check if echo state changed. Returns new state if changed, None otherwise.""" + current = self._detect_echo_state() + if current != self._echo_enabled: + self._echo_enabled = current + return current + return None + + @property + def echo_enabled(self) -> bool: + """Return whether the PTY currently has echo enabled.""" + return self._echo_enabled + + @property + def closed(self) -> bool: + """Return whether the session has been closed.""" + return self._closed + + async def read_output(self, select_timeout: float = 0.1) -> bytes: + """Read output from the PTY master.""" if self._master_fd is None or self._closed: - return - + return b"" try: - data = os.read(self._master_fd, 4096) - except (OSError, IOError) as exc: - logger.debug("PTY read error for session %s: %s", self.session_id, exc) - self._handle_eof() - return - - if not data: - # EOF: docker exec process exited - logger.debug("PTY EOF for session %s", self.session_id) - self._handle_eof() - return - - self._add_to_buffer(data) - self.last_activity = time.time() - - # Queue for batching + flow control - self._queue_output(data) - - def _add_to_buffer(self, data: bytes) -> None: - """Add data to circular buffer, maintaining size limit.""" - self._output_buffer.append(data) - self._buffer_size += len(data) - while self._buffer_size > self.BUFFER_SIZE and self._output_buffer: - removed = self._output_buffer.popleft() - self._buffer_size -= len(removed) - - def _queue_output(self, data: bytes) -> None: - """Add output to batch buffer and schedule flush.""" - self._batch_buffer.extend(data) - self._unacknowledged_bytes += len(data) - - # Check flow control - if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused: - self._pause_output() - - # Schedule batch flush if not already scheduled - if self._batch_timer is None: - loop = asyncio.get_event_loop() - self._batch_timer = loop.call_later( - self.BATCH_WINDOW_S, - self._flush_batch_sync, + readable, _, _ = select.select( + [self._master_fd], + [], + [], + select_timeout, ) - - def _flush_batch_sync(self) -> None: - """Synchronous entry point for batch flush (called from event loop).""" - self._batch_timer = None - if not self._batch_buffer or not self._websockets: - self._batch_buffer.clear() - return - - payload = bytes(self._batch_buffer) - self._batch_buffer.clear() - - # Send to all websockets (asyncio.create_task for async send) - dead_sockets = set() - for ws in list(self._websockets): - try: - asyncio.create_task(self._send_bytes(ws, payload)) - except Exception: - dead_sockets.add(ws) - - if dead_sockets: - self._websockets -= dead_sockets - - async def _send_bytes(self, ws: Any, payload: bytes) -> None: - """Send bytes to a single websocket, catching errors.""" - try: - await ws.send_bytes(payload) - except Exception: - self._websockets.discard(ws) - - def acknowledge_data(self, char_count: int) -> None: - """Client acknowledges processing char_count bytes. - - Called from the WebSocket handler when the client sends an 'ack' message. - """ - self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count) - - if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME: - self._resume_output() - - # Reset ack timeout - if self._ack_timeout_handle: - self._ack_timeout_handle.cancel() - loop = asyncio.get_event_loop() - self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback) - - def _ack_timeout_fallback(self) -> None: - """If no ack received for 5s, assume client is dead and resume.""" - logger.warning( - "Flow control ack timeout for session %s, resuming output", - self.session_id, - ) - self._unacknowledged_bytes = 0 - if self._paused: - self._resume_output() - - def _pause_output(self) -> None: - """Pause reading from PTY due to flow control.""" - self._paused = True - self._stop_reading() - logger.debug( - "Paused output for session %s (%d unacked)", - self.session_id, - self._unacknowledged_bytes, - ) - - def _resume_output(self) -> None: - """Resume reading from PTY.""" - self._paused = False - self._start_reading() - logger.debug("Resumed output for session %s", self.session_id) - - def get_buffer(self) -> bytes: - """Get buffered output for replay.""" - return b"".join(self._output_buffer) - - def _handle_eof(self) -> None: - """Handle PTY EOF: process died, close websockets to force reconnect.""" - self._stop_reading() - # Mark process as done so is_alive() returns False - if self.process is not None and self.process.returncode is None: - # Force returncode to a non-None value since the process is dead - # but asyncio.subprocess may not have set it yet - try: - self.process._transport.close() # type: ignore[attr-defined] - except Exception: - pass - # Close all websockets to force frontend reconnection - dead_sockets = set(self._websockets) - self._websockets.clear() - for ws in dead_sockets: - try: - asyncio.create_task( - ws.close(code=4001, reason="Session process exited") - ) - except Exception: - pass - logger.info("Session %s EOF handled, websockets closed", self.session_id) + if readable: + return os.read(self._master_fd, 8192) + return b"" + except (OSError, ValueError): + return b"" async def write_input(self, data: bytes) -> None: """Write input to the PTY master.""" if self._master_fd is None or self._closed: return - try: + with contextlib.suppress(OSError): os.write(self._master_fd, data) - self.last_activity = time.time() - except (OSError, IOError) as exc: - logger.debug("PTY write error for session %s: %s", self.session_id, exc) - self._handle_eof() - - def _set_terminal_size(self, cols: int, rows: int) -> None: - """Set the terminal size using TIOCSWINSZ.""" - if self._master_fd is None: - logger.warning("Cannot resize: master_fd is None (session not started)") - return - TIOCSWINSZ = 0x5414 - size = struct.pack("HHHH", rows, cols, 0, 0) - try: - fcntl.ioctl(self._master_fd, TIOCSWINSZ, size) - logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd) - except (OSError, IOError) as e: - logger.error("Failed to resize PTY: %s", e) async def resize(self, cols: int, rows: int) -> None: """Resize the terminal.""" if self._closed: - logger.warning("Cannot resize: session is closed") return - - if cols == self._cols and rows == self._rows: - return - - self._cols = cols - self._rows = rows - logger.debug( - "resize() called for session %s: %sx%s", self.session_id, cols, rows - ) self._set_terminal_size(cols, rows) - # Send SIGWINCH to docker exec process - if self.process and self.process.pid: - try: - os.kill(self.process.pid, signal.SIGWINCH) - except ProcessLookupError: - logger.warning("docker exec process %s not found", self.process.pid) - except Exception as e: - logger.warning("Failed to send SIGWINCH: %s", e) - - async def reset(self) -> None: - """Reset the session by killing the process and clearing state.""" - self.status = "resetting" - await self.close() - self._closed = False - self._output_buffer.clear() - self._buffer_size = 0 - self._websockets.clear() - self._batch_buffer.clear() - self._batch_timer = None - self._unacknowledged_bytes = 0 - self._paused = False - self._read_handler_set = False - self.process = None - self._master_fd = None - self.status = "active" + def get_exit_reason(self) -> str | None: + """Return the reason the session ended, if known.""" + return self._exit_reason async def close(self) -> None: """Close the session and cleanup.""" if self._closed: return self._closed = True - self.status = "closed" - self._stop_reading() - - if self._batch_timer: - self._batch_timer.cancel() - self._batch_timer = None - - if self._ack_timeout_handle: - self._ack_timeout_handle.cancel() - self._ack_timeout_handle = None + # Determine exit reason + if self.process is not None and self.process.returncode is not None: + if self.process.returncode == 0: + self._exit_reason = "process_exit" + else: + self._exit_reason = "process_exit" + else: + self._exit_reason = "timeout" if self._master_fd is not None: - try: + with contextlib.suppress(OSError): os.close(self._master_fd) - except OSError: - pass self._master_fd = None if self.process is not None: try: self.process.kill() await asyncio.wait_for(self.process.wait(), timeout=2.0) - except (asyncio.TimeoutError, ProcessLookupError): + except (TimeoutError, ProcessLookupError): pass def is_alive(self) -> bool: @@ -427,41 +160,3 @@ class TerminalSession: if self.process is None: return False return self.process.returncode is None - - def is_idle(self) -> bool: - """Check if the session has been idle for too long.""" - if self._websockets: - return False - return time.time() - self.last_activity > self.IDLE_TIMEOUT - - def attach_websocket(self, websocket: Any) -> None: - """Attach a WebSocket to this session.""" - self._websockets.add(websocket) - self.last_activity = time.time() - - def detach_websocket(self, websocket: Any) -> None: - """Detach a WebSocket from this session.""" - self._websockets.discard(websocket) - - def has_websockets(self) -> bool: - """Check if any WebSockets are attached.""" - return len(self._websockets) > 0 - - async def send_to_all(self, data: bytes) -> None: - """Send data to all attached WebSockets (used for control messages).""" - dead_sockets = set() - for ws in self._websockets: - try: - await ws.send_bytes(data) - except Exception: - dead_sockets.add(ws) - for ws in dead_sockets: - self._websockets.discard(ws) - - async def read_output(self) -> bytes: - """Legacy method: read output synchronously. - - With event-driven I/O, output is automatically sent to websockets. - This method returns any buffered data for callers that poll. - """ - return b"" diff --git a/apps/api/src/utils/git_control.py b/apps/api/src/utils/git_control.py index 6f17d2d..becebc1 100644 --- a/apps/api/src/utils/git_control.py +++ b/apps/api/src/utils/git_control.py @@ -2,6 +2,7 @@ import subprocess from dataclasses import dataclass, field +from typing import Any def _run_git_command(repo_path: str, *args: str) -> str: @@ -123,15 +124,7 @@ def create_branch(repo_path: str, name: str, base_branch: str = "HEAD") -> None: try: _run_git_command(repo_path, "rev-parse", "--verify", "HEAD^{commit}") except RuntimeError: - # No commits yet - empty repository - try: - _run_git_command(repo_path, "checkout", "--orphan", name) - except RuntimeError as e: - if "work tree" in str(e).lower(): - # Bare repository - use symbolic-ref instead - _run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}") - return - raise + _run_git_command(repo_path, "checkout", "--orphan", name) return _run_git_command(repo_path, "branch", name, base_branch) @@ -162,14 +155,7 @@ def checkout_branch(repo_path: str, name: str) -> None: Raises: RuntimeError: If checkout fails """ - try: - _run_git_command(repo_path, "checkout", name) - except RuntimeError as e: - if "work tree" in str(e).lower(): - # Bare repository - use symbolic-ref instead - _run_git_command(repo_path, "symbolic-ref", "HEAD", f"refs/heads/{name}") - return - raise + _run_git_command(repo_path, "checkout", name) def commit_changes( @@ -304,10 +290,6 @@ def get_current_branch(repo_path: str) -> str: Current branch name """ try: - branch = _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() - if branch != "HEAD": - return branch + return _run_git_command(repo_path, "rev-parse", "--abbrev-ref", "HEAD").strip() except RuntimeError: - pass - - return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip() + return _run_git_command(repo_path, "symbolic-ref", "--short", "HEAD").strip() diff --git a/apps/api/src/utils/git_files.py b/apps/api/src/utils/git_files.py index a535d5e..147b655 100644 --- a/apps/api/src/utils/git_files.py +++ b/apps/api/src/utils/git_files.py @@ -289,37 +289,19 @@ def list_branches(repo_path: str) -> tuple[list[BranchInfo], str]: branches: list[BranchInfo] = [] default_branch = "main" - # Get list of remote names to properly filter remote tracking branches - try: - remote_output = _run_git_command(repo_path, "remote") - remote_names = {r.strip() for r in remote_output.strip().split("\n") if r.strip()} - except RuntimeError: - remote_names = set() - for line in output.strip().split("\n"): if not line: continue branch_name = line.strip() - - # Skip detached HEAD pointer - if branch_name == "HEAD": - continue - - # Skip remote tracking branches - they appear as "origin/branch-name" - # Check if first part is a remote name - if "/" in branch_name: - first_part = branch_name.split("/", 1)[0] - if first_part in remote_names: - # Extract just the branch name part (after "origin/") - branch_name = branch_name.split("/", 1)[1] - elif branch_name.startswith("remotes/"): - # Handle "remotes/origin/branch-name" format - parts = branch_name.split("/", 2) - if len(parts) >= 3: - branch_name = parts[2] - else: - continue + # Skip remote tracking branches (they start with remotes/) + if branch_name.startswith("remotes/"): + # Extract just the branch name part + parts = branch_name.split("/", 2) + if len(parts) >= 3: + branch_name = parts[2] + else: + continue # Skip duplicates if any(b.name == branch_name for b in branches): diff --git a/apps/api/src/utils/git_history.py b/apps/api/src/utils/git_history.py index 7f18dcd..9001954 100644 --- a/apps/api/src/utils/git_history.py +++ b/apps/api/src/utils/git_history.py @@ -60,9 +60,12 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 Returns structured data including commits, branches, and graph information. """ - # Get list of branches - branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"]) - branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()] + # Get list of branches (may fail for empty repos) + try: + branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"]) + branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()] + except RuntimeError: + branches = [] # Build git log command - use NULL bytes as separators to avoid parsing issues log_args = [ @@ -76,7 +79,16 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 else: log_args.append("--all") - log_output = _run_git_command(repo_path, log_args) + try: + log_output = _run_git_command(repo_path, log_args) + except RuntimeError: + # Empty repo or no commits + return { + "commits": [], + "branches": branches, + "total_commits": 0, + "graph_data": {"nodes": [], "edges": []}, + } # Get branch info for each commit branch_map = _get_branch_map(repo_path) @@ -113,8 +125,11 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1 ) # Get total commit count - count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"]) - total_commits = int(count_output.strip()) if count_output.strip() else 0 + try: + count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"]) + total_commits = int(count_output.strip()) if count_output.strip() else 0 + except RuntimeError: + total_commits = 0 # Build graph data and generate graph symbols graph_data = _build_graph_data(commits) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index 689a240..a940330 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -8,14 +8,16 @@ from unittest.mock import patch import pytest import pytest_asyncio from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import sessionmaker # Set test environment BEFORE importing app modules os.environ["APP_ENV"] = "testing" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only-do-not-use-in-production" os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" -from src.config import Settings +from src.config import Settings, build_database_url from src.models.base import Base from src.main import app from src.auth.dependencies import get_db_session @@ -45,8 +47,10 @@ 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: + with patch("src.main.init_database") as mock_init, \ + patch("src.main.seed_builtin_tool_types") as mock_seed: mock_init.return_value = True + mock_seed.return_value = None try: with TestClient(app) as client: @@ -57,31 +61,6 @@ 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.""" @@ -131,65 +110,6 @@ def authenticated_client(test_client) -> Generator[TestClient, None, None]: yield test_client -@pytest.fixture -def test_project_and_repo(authenticated_client) -> tuple[str, str]: - """Create a project and repository directly in the database.""" - import uuid - from src.models.project import Project - from src.models.git_repository import GitRepository - - project_id = uuid.uuid4() - repo_id = uuid.uuid4() - user_id = None - - # Get user ID from session - async def get_user_id(): - nonlocal user_id - from src.auth.session import decode_session_cookie - settings = Settings() - session_cookie = authenticated_client.cookies.get("session") - if session_cookie: - session = decode_session_cookie(settings=settings, cookie_value=session_cookie) - if session: - user_id = uuid.UUID(session["user_id"]) - - asyncio.run(get_user_id()) - - if not user_id: - raise RuntimeError("Could not get user ID from authenticated client") - - async def create_project_and_repo(): - override_fn = app.dependency_overrides.get(get_db_session) - if override_fn: - gen = override_fn() - session = await gen.asend(None) - try: - project = Project( - id=project_id, - name="test-project", - description="Test project", - owner_id=user_id, - ) - session.add(project) - - repo = GitRepository( - id=repo_id, - name="test-repo", - path="/tmp/test-repo", - project_id=project_id, - owner_id=user_id, - remote_url="https://github.com/test/repo.git", - ) - session.add(repo) - await session.commit() - finally: - await gen.aclose() - - asyncio.run(create_project_and_repo()) - - return str(project_id), str(repo_id) - - @pytest.fixture def admin_client(test_client) -> Generator[TestClient, None, None]: """Provide an authenticated test client with an admin user.""" diff --git a/apps/api/tests/integration/test_config_folders_api.py b/apps/api/tests/integration/test_config_folders_api.py new file mode 100644 index 0000000..d8b0b66 --- /dev/null +++ b/apps/api/tests/integration/test_config_folders_api.py @@ -0,0 +1,255 @@ +import uuid +import pytest +from fastapi.testclient import TestClient + + +@pytest.mark.integration +class TestConfigFoldersAPI: + """Integration tests for config folders API.""" + + def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None: + """Test that listing config folders requires authentication.""" + response = test_client.get("/config-folders") + assert response.status_code == 401 + + def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None: + """Test that authenticated users can list their folders.""" + response = authenticated_client.get("/config-folders") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + assert "folders" in data + assert isinstance(data["folders"], list) + + def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None: + """Test creating a config folder.""" + response = authenticated_client.post( + "/config-folders", + json={ + "name": "test-folder", + "description": "Test folder", + "mount_path": "/home/user", + "files": {"test.txt": "hello world"}, + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "test-folder" + assert data["mount_path"] == "/home/user" + assert data["files"] == {"test.txt": "hello world"} + + def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None: + """Test that duplicate folder names are rejected.""" + # Create first folder + response = authenticated_client.post( + "/config-folders", + json={ + "name": "duplicate-folder", + "mount_path": "/home/user", + "files": {}, + }, + ) + assert response.status_code == 201 + + # Try to create second with same name + response = authenticated_client.post( + "/config-folders", + json={ + "name": "duplicate-folder", + "mount_path": "/home/user", + "files": {}, + }, + ) + assert response.status_code == 409 + + def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None: + """Test that folders exceeding 10MB are rejected.""" + large_content = "x" * (11 * 1024 * 1024) # 11MB + response = authenticated_client.post( + "/config-folders", + json={ + "name": "large-folder", + "mount_path": "/home/user", + "files": {"large.txt": large_content}, + }, + ) + assert response.status_code == 422 + + def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None: + """Test that path traversal in file paths is prevented.""" + response = authenticated_client.post( + "/config-folders", + json={ + "name": "bad-folder", + "mount_path": "/home/user", + "files": {"../../../etc/passwd": "malicious"}, + }, + ) + assert response.status_code == 422 + + def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None: + """Test getting a config folder by ID.""" + # Create folder first + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "get-test", + "mount_path": "/home/user", + "files": {}, + }, + ) + folder_id = create_response.json()["id"] + + # Get it back + response = authenticated_client.get(f"/config-folders/{folder_id}") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "get-test" + + def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None: + """Test getting a non-existent folder.""" + response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}") + assert response.status_code == 404 + + def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None: + """Test updating a config folder.""" + # Create folder first + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "update-test", + "mount_path": "/home/user", + "files": {}, + }, + ) + folder_id = create_response.json()["id"] + + # Update it + response = authenticated_client.put( + f"/config-folders/{folder_id}", + json={ + "name": "updated-name", + "mount_path": "/workspace", + "files": {"new.txt": "content"}, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "updated-name" + assert data["mount_path"] == "/workspace" + + def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None: + """Test deleting a config folder.""" + # Create folder first + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "delete-test", + "mount_path": "/home/user", + "files": {}, + }, + ) + folder_id = create_response.json()["id"] + + # Delete it + response = authenticated_client.delete(f"/config-folders/{folder_id}") + assert response.status_code == 204 + + # Verify it's gone + get_response = authenticated_client.get(f"/config-folders/{folder_id}") + assert get_response.status_code == 404 + + def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None: + """Test adding a project override.""" + # Create folder first + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "override-test", + "mount_path": "/home/user", + "files": {"global.txt": "global"}, + }, + ) + folder_id = create_response.json()["id"] + project_id = str(uuid.uuid4()) + + # Add override + response = authenticated_client.post( + f"/config-folders/{folder_id}/overrides", + json={ + "project_id": project_id, + "mount_path": "/workspace", + "files": {"project.txt": "project"}, + }, + ) + assert response.status_code == 200 + data = response.json() + assert project_id in data["project_overrides"] + + def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None: + """Test updating a project override.""" + # Create folder with override + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "update-override-test", + "mount_path": "/home/user", + "files": {}, + }, + ) + folder_id = create_response.json()["id"] + project_id = str(uuid.uuid4()) + + # Add override + authenticated_client.post( + f"/config-folders/{folder_id}/overrides", + json={ + "project_id": project_id, + "mount_path": "/workspace", + "files": {"old.txt": "old"}, + }, + ) + + # Update override + response = authenticated_client.put( + f"/config-folders/{folder_id}/overrides/{project_id}", + json={ + "mount_path": "/app", + "files": {"new.txt": "new"}, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["project_overrides"][project_id]["mount_path"] == "/app" + + def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None: + """Test deleting a project override.""" + # Create folder with override + create_response = authenticated_client.post( + "/config-folders", + json={ + "name": "delete-override-test", + "mount_path": "/home/user", + "files": {}, + }, + ) + folder_id = create_response.json()["id"] + project_id = str(uuid.uuid4()) + + # Add override + authenticated_client.post( + f"/config-folders/{folder_id}/overrides", + json={ + "project_id": project_id, + "mount_path": "/workspace", + "files": {}, + }, + ) + + # Delete override + response = authenticated_client.delete( + f"/config-folders/{folder_id}/overrides/{project_id}" + ) + assert response.status_code == 200 + data = response.json() + assert project_id not in data["project_overrides"] diff --git a/apps/api/tests/integration/test_config_profiles_api.py b/apps/api/tests/integration/test_config_profiles_api.py index b1a6011..06c745f 100644 --- a/apps/api/tests/integration/test_config_profiles_api.py +++ b/apps/api/tests/integration/test_config_profiles_api.py @@ -1,4 +1,7 @@ +"""Integration tests for config profiles API.""" + import uuid + import pytest from fastapi.testclient import TestClient @@ -17,7 +20,9 @@ class TestConfigProfilesAPI: response = authenticated_client.get("/config-profiles") assert response.status_code == 200 data = response.json() - assert isinstance(data, list) + assert isinstance(data, dict) + assert "profiles" in data + assert isinstance(data["profiles"], list) def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None: """Test creating a config profile.""" @@ -26,99 +31,48 @@ class TestConfigProfilesAPI: 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" + assert data["description"] == "Test profile" 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( + authenticated_client.post( "/config-profiles", - json={ - "name": "duplicate-profile", - "env_vars": {}, - "files": {}, - }, + json={"name": "duplicate-profile"}, ) - 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": {}, - }, + json={"name": "duplicate-profile"}, ) 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 + def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None: + """Test that empty profile names are rejected.""" 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": {}}], - }, + json={"name": " "}, ) 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": {}, - }, + json={"name": "get-test"}, ) 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" + assert "includes" in data + assert "mounts" in data def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None: """Test getting a non-existent profile.""" @@ -127,327 +81,381 @@ class TestConfigProfilesAPI: 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": {}, - }, + json={"name": "update-test"}, ) 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"}, - }, + json={"name": "updated-name", "description": "updated desc"}, ) assert response.status_code == 200 data = response.json() assert data["name"] == "updated-name" - assert data["env_vars"] == {"NEW_VAR": "new_value"} + assert data["description"] == "updated desc" 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": {}, - }, + json={"name": "delete-test"}, ) 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( + def test_profile_access_check(self, authenticated_client: TestClient) -> None: + """Test that users can only access their own profiles.""" + # Create a profile + create_response = authenticated_client.post( "/config-profiles", - json={ - "name": "base-profile", - "env_vars": {"BASE_VAR": "base_value"}, - "files": {}, - }, + json={"name": "access-test"}, ) - base_id = base_response.json()["id"] + profile_id = create_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]}, - ) + # The profile should be accessible + response = authenticated_client.get(f"/config-profiles/{profile_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( + +@pytest.mark.integration +class TestConfigProfileIncludes: + """Integration tests for config profile includes.""" + + def test_add_include_successfully(self, authenticated_client: TestClient) -> None: + """Test adding an include to a profile.""" + # Create two profiles + profile1 = authenticated_client.post( "/config-profiles", - json={ - "name": "profile-a", - "env_vars": {}, - "files": {}, - }, - ) - a_id = a_response.json()["id"] - - # Create profile B - b_response = authenticated_client.post( + json={"name": "profile-1"}, + ).json() + profile2 = authenticated_client.post( "/config-profiles", - json={ - "name": "profile-b", - "env_vars": {}, - "files": {}, - }, - ) - b_id = b_response.json()["id"] - - # Make B include A - authenticated_client.put( - f"/config-profiles/{b_id}/includes", - json={"includes": [a_id]}, - ) - - # Try to make A include B (would create cycle) - response = authenticated_client.put( - f"/config-profiles/{a_id}/includes", - json={"includes": [b_id]}, - ) - assert response.status_code == 400 - - def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None: - """Test previewing a resolved config profile.""" - # Create base profile - base_response = authenticated_client.post( - "/config-profiles", - json={ - "name": "preview-base", - "env_vars": {"BASE_VAR": "base"}, - "files": {}, - }, - ) - base_id = base_response.json()["id"] - - # Create child profile - child_response = authenticated_client.post( - "/config-profiles", - json={ - "name": "preview-child", - "env_vars": {"CHILD_VAR": "child"}, - "files": {}, - }, - ) - child_id = child_response.json()["id"] - - # Make child include base - authenticated_client.put( - f"/config-profiles/{child_id}/includes", - json={"includes": [base_id]}, - ) - - # Preview child - response = authenticated_client.get(f"/config-profiles/{child_id}/preview") - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "preview-child" - assert data["env_vars"]["BASE_VAR"] == "base" - assert data["env_vars"]["CHILD_VAR"] == "child" - assert len(data["included_profiles"]) == 1 - - def test_resolve_default_profile(self, authenticated_client: TestClient) -> None: - """Test resolving default profile for project/tool.""" - # Create a global default profile (no project/tool scoping) - authenticated_client.post( - "/config-profiles", - json={ - "name": "default-profile", - "env_vars": {}, - "files": {}, - "is_default": True, - }, - ) - - # Resolve default with random project/tool (should fall back to global) - project_id = str(uuid.uuid4()) - tool_type_id = str(uuid.uuid4()) - response = authenticated_client.get( - "/config-profiles/defaults/resolve", - params={"project_id": project_id, "tool_type_id": tool_type_id}, - ) - assert response.status_code == 200 - data = response.json() - assert data["profile_name"] == "default-profile" - - def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None: - """Test resolving default profile when no profiles exist.""" - project_id = str(uuid.uuid4()) - tool_type_id = str(uuid.uuid4()) - - response = authenticated_client.get( - "/config-profiles/defaults/resolve", - params={"project_id": project_id, "tool_type_id": tool_type_id}, - ) - assert response.status_code == 200 - data = response.json() - assert data["profile_id"] is None - - def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test creating a config profile with git mounts.""" - _project_id, repo_id = test_project_and_repo + json={"name": "profile-2"}, + ).json() + # Add include response = authenticated_client.post( - "/config-profiles", - json={ - "name": "git-mount-profile", - "env_vars": {}, - "files": {}, - "git_mounts": [ - { - "remote_url": "https://github.com/user/repo.git", - "source_path": ".", - "target_path": "/app", - "branch": "main", - } - ], - }, + f"/config-profiles/{profile1['id']}/includes", + json={"included_profile_id": profile2["id"], "order_index": 0}, ) assert response.status_code == 201 data = response.json() - assert data["name"] == "git-mount-profile" - assert len(data["git_mounts"]) == 1 - assert data["git_mounts"][0]["target_path"] == "/app" - assert data["git_mounts"][0]["branch"] == "main" + assert data["included_profile_id"] == profile2["id"] + assert data["included_profile_name"] == "profile-2" - def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test updating git mounts on a config profile.""" - _project_id, repo_id = test_project_and_repo - - # Create profile first - create_response = authenticated_client.post( + def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None: + """Test that self-includes are rejected.""" + profile = authenticated_client.post( "/config-profiles", - json={ - "name": "update-git-mounts", - "env_vars": {}, - "files": {}, - }, - ) - profile_id = create_response.json()["id"] + json={"name": "self-include-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/includes", + json={"included_profile_id": profile["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None: + """Test that circular includes are rejected.""" + profile1 = authenticated_client.post( + "/config-profiles", + json={"name": "cycle-1"}, + ).json() + profile2 = authenticated_client.post( + "/config-profiles", + json={"name": "cycle-2"}, + ).json() + + # Add profile1 includes profile2 + authenticated_client.post( + f"/config-profiles/{profile1['id']}/includes", + json={"included_profile_id": profile2["id"], "order_index": 0}, + ) + + # Try to add profile2 includes profile1 (creates cycle) + response = authenticated_client.post( + f"/config-profiles/{profile2['id']}/includes", + json={"included_profile_id": profile1["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None: + """Test that deep circular includes are rejected.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "deep-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "deep-2"} + ).json() + p3 = authenticated_client.post( + "/config-profiles", json={"name": "deep-3"} + ).json() + + # p1 -> p2 -> p3 + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + authenticated_client.post( + f"/config-profiles/{p2['id']}/includes", + json={"included_profile_id": p3["id"], "order_index": 0}, + ) + + # Try p3 -> p1 (creates cycle) + response = authenticated_client.post( + f"/config-profiles/{p3['id']}/includes", + json={"included_profile_id": p1["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None: + """Test that duplicate includes are rejected.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "dup-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "dup-2"} + ).json() + + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + + response = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 1}, + ) + assert response.status_code == 409 + + def test_list_includes(self, authenticated_client: TestClient) -> None: + """Test listing includes for a profile.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "list-inc-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "list-inc-2"} + ).json() + + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + + response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes") + assert response.status_code == 200 + data = response.json() + assert len(data["includes"]) == 1 + + def test_update_include_order(self, authenticated_client: TestClient) -> None: + """Test updating include order index.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "order-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "order-2"} + ).json() + + inc = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ).json() - # Update with git mounts response = authenticated_client.put( - f"/config-profiles/{profile_id}", - json={ - "git_mounts": [ - { - "remote_url": "https://github.com/user/repo.git", - "source_path": "config", - "target_path": "/config", - } - ], - }, + f"/config-profiles/{p1['id']}/includes/{inc['id']}", + json={"order_index": 5}, ) assert response.status_code == 200 - data = response.json() - assert len(data["git_mounts"]) == 1 - assert data["git_mounts"][0]["source_path"] == "config" + assert response.json()["order_index"] == 5 - def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test that invalid git mount source paths are rejected.""" - _project_id, repo_id = test_project_and_repo + def test_remove_include(self, authenticated_client: TestClient) -> None: + """Test removing an include.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "rem-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "rem-2"} + ).json() + + inc = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ).json() + + response = authenticated_client.delete( + f"/config-profiles/{p1['id']}/includes/{inc['id']}" + ) + assert response.status_code == 204 + + +@pytest.mark.integration +class TestConfigProfileMounts: + """Integration tests for config profile mounts.""" + + def test_add_mount_successfully(self, authenticated_client: TestClient) -> None: + """Test adding a mount to a profile.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "mount-test"}, + ).json() response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0}, + ) + assert response.status_code == 201 + data = response.json() + assert data["target_path"] == "/etc/config" + assert data["files"] == {"test.txt": "hello"} + + def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None: + """Test that relative mount paths are rejected.""" + profile = authenticated_client.post( "/config-profiles", - json={ - "name": "bad-git-mount", - "env_vars": {}, - "files": {}, - "git_mounts": [ - { - "remote_url": "https://github.com/user/repo.git", - "source_path": "/absolute/path", - "target_path": "/app", - } - ], - }, + json={"name": "rel-path-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "etc/config", "files": {"test.txt": "hello"}}, ) assert response.status_code == 422 - def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test that git mount target paths with traversal are rejected.""" - _project_id, repo_id = test_project_and_repo + def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None: + """Test that path traversal in mount paths is rejected.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "traversal-test"}, + ).json() response = authenticated_client.post( - "/config-profiles", - json={ - "name": "bad-git-mount-target", - "env_vars": {}, - "files": {}, - "git_mounts": [ - { - "remote_url": "https://github.com/user/repo.git", - "source_path": ".", - "target_path": "../../../etc/passwd", - } - ], - }, + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}}, ) assert response.status_code == 422 - def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None: - """Test previewing a profile with git mounts.""" - _project_id, repo_id = test_project_and_repo - - # Create profile with git mounts - create_response = authenticated_client.post( + def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None: + """Test that duplicate mount paths are rejected.""" + profile = authenticated_client.post( "/config-profiles", - json={ - "name": "preview-git-mounts", - "env_vars": {}, - "files": {}, - "git_mounts": [ - { - "remote_url": "https://github.com/user/repo.git", - "source_path": ".", - "target_path": "/app", - } - ], - }, - ) - profile_id = create_response.json()["id"] + json={"name": "dup-mount-test"}, + ).json() - # Preview - response = authenticated_client.get(f"/config-profiles/{profile_id}/preview") + authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "hello"}}, + ) + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "world"}}, + ) + assert response.status_code == 409 + + def test_update_mount(self, authenticated_client: TestClient) -> None: + """Test updating a mount.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "update-mount-test"}, + ).json() + + mount = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/old/path", "files": {"test.txt": "old"}}, + ).json() + + response = authenticated_client.put( + f"/config-profiles/{profile['id']}/mounts/{mount['id']}", + json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2}, + ) assert response.status_code == 200 data = response.json() - assert len(data["git_mounts"]) == 1 - assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git" + assert data["target_path"] == "/new/path" + assert data["files"] == {"test.txt": "new"} + assert data["order_index"] == 2 + + def test_remove_mount(self, authenticated_client: TestClient) -> None: + """Test removing a mount.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "rem-mount-test"}, + ).json() + + mount = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/tmp/test", "files": {"test.txt": "x"}}, + ).json() + + response = authenticated_client.delete( + f"/config-profiles/{profile['id']}/mounts/{mount['id']}" + ) + assert response.status_code == 204 + + +@pytest.mark.integration +class TestConfigProfileDefaults: + """Integration tests for default profile APIs.""" + + def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None: + """Test getting default profiles when none are set.""" + response = authenticated_client.get("/config-profiles/defaults") + assert response.status_code == 200 + data = response.json() + assert data["default_profiles"] == {} + + def test_set_default_profiles(self, authenticated_client: TestClient) -> None: + """Test setting default profiles.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "default-test"}, + ).json() + + response = authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"code-server": profile["id"]}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["default_profiles"]["code-server"] == profile["id"] + + def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None: + """Test setting default profiles with invalid profile ID.""" + response = authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"code-server": str(uuid.uuid4())}}, + ) + assert response.status_code == 404 + + def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None: + """Test getting default profile for a specific tool type.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "tool-default-test"}, + ).json() + + authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"jupyter-notebook": profile["id"]}}, + ) + + response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook") + assert response.status_code == 200 + data = response.json() + assert data["tool_type_id"] == "jupyter-notebook" + assert data["profile_id"] == profile["id"] + + def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None: + """Test getting default profile when not set.""" + response = authenticated_client.get("/config-profiles/defaults/opencode") + assert response.status_code == 200 + data = response.json() + assert data["tool_type_id"] == "opencode" + assert data["profile_id"] is None diff --git a/apps/api/tests/integration/test_git_control.py b/apps/api/tests/integration/test_git_control.py index 37d5425..a7ab139 100644 --- a/apps/api/tests/integration/test_git_control.py +++ b/apps/api/tests/integration/test_git_control.py @@ -70,20 +70,6 @@ def test_get_current_branch_handles_unborn_main() -> None: assert get_current_branch(tmpdir) == "main" -def test_create_branch_on_bare_repo_with_no_commits() -> None: - with tempfile.TemporaryDirectory() as tmpdir: - os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1") - create_branch(f"{tmpdir}/bare.git", "main") - assert get_current_branch(f"{tmpdir}/bare.git") == "main" - - -def test_checkout_branch_on_bare_repo_with_no_commits() -> None: - with tempfile.TemporaryDirectory() as tmpdir: - os.system(f"git init --bare {tmpdir}/bare.git >/dev/null 2>&1") - checkout_branch(f"{tmpdir}/bare.git", "main") - assert get_current_branch(f"{tmpdir}/bare.git") == "main" - - class TestBranchOperations: """Tests for branch management functions.""" diff --git a/apps/api/tests/integration/test_models.py b/apps/api/tests/integration/test_models.py index 04c3f72..88f36d4 100644 --- a/apps/api/tests/integration/test_models.py +++ b/apps/api/tests/integration/test_models.py @@ -16,6 +16,7 @@ def test_base_metadata_collects_declared_tables() -> None: @pytest.mark.integration + def test_shared_mixins_define_expected_columns() -> None: assert "id" in UUIDPrimaryKeyMixin.__dict__ assert "created_at" in TimestampMixin.__dict__ @@ -23,26 +24,20 @@ def test_shared_mixins_define_expected_columns() -> None: @pytest.mark.integration + def test_expected_tables_are_registered() -> None: assert set(Base.metadata.tables) == { - "config_profile_includes", - "config_profiles", + "refresh_tokens", "git_repositories", - "health_checks", - "instance_events", - "notifications", "projects", "ssh_keys", - "terminal_sessions", - "tool_definition_manifests", - "tool_instances", - "tool_types", "user_configs", "users", } @pytest.mark.integration + def test_user_table_has_required_columns() -> None: columns = User.__table__.columns @@ -61,6 +56,7 @@ def test_user_table_has_required_columns() -> None: @pytest.mark.integration + def test_project_relationships_point_to_owner_and_default_ssh_key() -> None: owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys)) ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys)) @@ -72,6 +68,7 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None: @pytest.mark.integration + def test_repository_and_user_config_relationships_are_registered() -> None: project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys)) owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys)) @@ -85,15 +82,33 @@ def test_repository_and_user_config_relationships_are_registered() -> None: assert UserConfig.user.property.mapper.class_ is User +@pytest.mark.integration + +def test_refresh_token_table_has_required_columns_and_relationships() -> None: + columns = RefreshToken.__table__.columns + user_fk = next(iter(RefreshToken.__table__.c.user_id.foreign_keys)) + + assert set(columns.keys()) == { + "id", + "user_id", + "token_hash", + "expires_at", + "revoked_at", + "user_agent", + "ip_address", + "created_at", + } + assert columns["token_hash"].unique is True + assert columns["revoked_at"].nullable is True + assert user_fk.target_fullname == "users.id" + assert RefreshToken.user.property.mapper.class_ is User + + @pytest.mark.asyncio @pytest.mark.integration + async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None: - user = User( - email="dev@headquarter.local", - name="Dev User", - authentik_id="dev-user", - avatar_url=None, - ) + user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None) db_session.add(user) await db_session.commit() diff --git a/apps/api/tests/integration/test_projects_api.py b/apps/api/tests/integration/test_projects_api.py index 6fc491a..3c216c6 100644 --- a/apps/api/tests/integration/test_projects_api.py +++ b/apps/api/tests/integration/test_projects_api.py @@ -1,9 +1,8 @@ import uuid -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import asyncio import pytest -from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker @@ -59,7 +58,7 @@ def _mint_token(user_id: str) -> str: subject=user_id, email="test@headquarter.local", name="Test User", - expires_at=datetime.now(UTC) + timedelta(minutes=15), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) diff --git a/apps/api/tests/integration/test_tool_configs_api_extended.py b/apps/api/tests/integration/test_tool_configs_api_extended.py new file mode 100644 index 0000000..0489255 --- /dev/null +++ b/apps/api/tests/integration/test_tool_configs_api_extended.py @@ -0,0 +1,256 @@ +import uuid +import pytest +from fastapi.testclient import TestClient + + +@pytest.mark.integration +class TestToolConfigsAPIExtended: + """Integration tests for tool configs API with new fields.""" + + def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None: + """Test creating a tool config with all new fields.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "config-test-tool", + "display_name": "Config Test Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Create config with new fields + response = authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "ADVANCED_CONFIG", + "value": "test-value", + "config_type": "env", + "port_override": 9090, + "start_command": "python app.py", + "working_directory": "/app", + "environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"}, + "volumes": [ + {"source": "data", "target": "/data", "type": "bind"} + ], + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["key"] == "ADVANCED_CONFIG" + assert data["port_override"] == 9090 + assert data["start_command"] == "python app.py" + assert data["working_directory"] == "/app" + assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"} + assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}] + + def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None: + """Test that invalid port numbers are rejected.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "port-test-tool", + "display_name": "Port Test Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Try to create config with invalid port + response = authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "BAD_PORT", + "value": "test", + "config_type": "env", + "port_override": 99999, + }, + ) + assert response.status_code == 422 + + def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None: + """Test that invalid volume structures are rejected.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "volume-test-tool", + "display_name": "Volume Test Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Try to create config with invalid volume + response = authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "BAD_VOLUME", + "value": "test", + "config_type": "env", + "volumes": [{"invalid": "structure"}], + }, + ) + assert response.status_code == 422 + + def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None: + """Test updating a tool config with new fields.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "update-config-tool", + "display_name": "Update Config Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Create config + create_response = authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "UPDATE_TEST", + "value": "original", + "config_type": "env", + }, + ) + config_id = create_response.json()["id"] + + # Update with new fields + response = authenticated_client.put( + f"/tool-configs/{config_id}", + json={ + "value": "updated", + "port_override": 3000, + "start_command": "npm start", + "working_directory": "/workspace", + "environment_variables": {"NODE_ENV": "production"}, + "volumes": [{"source": "src", "target": "/app/src", "type": "bind"}], + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["value"] == "updated" + assert data["port_override"] == 3000 + assert data["start_command"] == "npm start" + assert data["working_directory"] == "/workspace" + assert data["environment_variables"] == {"NODE_ENV": "production"} + + def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None: + """Test that listing configs returns new fields.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "list-config-tool", + "display_name": "List Config Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Create config with new fields + authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "LIST_TEST", + "value": "test", + "config_type": "env", + "port_override": 5000, + "environment_variables": {"TEST": "true"}, + }, + ) + + # List configs + response = authenticated_client.get("/tool-configs") + assert response.status_code == 200 + data = response.json() + assert len(data) > 0 + config = data[0] + assert "port_override" in config + assert "start_command" in config + assert "working_directory" in config + assert "environment_variables" in config + assert "volumes" in config + + def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None: + """Test getting tool config defaults.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "defaults-tool", + "display_name": "Defaults Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n", + "required_variables": ["REPO_PATH"], + }, + ) + tool_id = tool_response.json()["id"] + + # Get defaults + response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}") + assert response.status_code == 200 + data = response.json() + assert data["tool_type_id"] == tool_id + assert "suggested_configs" in data + + def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None: + """Test that old configs without new fields still work.""" + # Create a tool type first + tool_response = authenticated_client.post( + "/tool-types", + json={ + "name": "backward-compat-tool", + "display_name": "Backward Compat Tool", + "default_port": 8080, + "definition_type": "compose", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", + "required_variables": [], + }, + ) + tool_id = tool_response.json()["id"] + + # Create config without new fields (simulating old client) + response = authenticated_client.post( + "/tool-configs", + json={ + "tool_type_id": tool_id, + "key": "OLD_STYLE", + "value": "value", + "config_type": "env", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["key"] == "OLD_STYLE" + # New fields should have default values + assert data["port_override"] is None + assert data["start_command"] is None + assert data["working_directory"] is None + assert data["environment_variables"] is None + assert data["volumes"] is None diff --git a/apps/api/tests/integration/test_tool_types_api.py b/apps/api/tests/integration/test_tool_types_api.py index b9df8dc..944b606 100644 --- a/apps/api/tests/integration/test_tool_types_api.py +++ b/apps/api/tests/integration/test_tool_types_api.py @@ -1,5 +1,5 @@ import uuid -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import asyncio import pytest @@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str: subject=user_id, email="test@headquarter.local", name="Test User", - expires_at=datetime.now(UTC) + timedelta(minutes=15), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) @@ -98,6 +98,7 @@ def _insert_tool_type( name: str, display_name: str, compose_template: str, + is_builtin: bool = False, created_by_id: str | None = None, ) -> None: async def _run() -> None: @@ -119,6 +120,7 @@ def _insert_tool_type( description="A test tool type", compose_template=compose_template, required_variables=["REPO_PATH", "TOOL_NAME"], + is_builtin=is_builtin, created_by_id=uuid.UUID(created_by_id) if created_by_id else None, ) await session.merge(tool_type) @@ -232,6 +234,7 @@ def test_create_tool_type_successfully() -> None: assert data["name"] == "my-custom-tool" assert data["display_name"] == "My Custom Tool" assert data["description"] == "A custom development tool" + assert data["is_builtin"] == False assert data["created_by_id"] == user_id assert "id" in data @@ -373,7 +376,28 @@ def test_update_tool_type_not_found() -> None: assert response.status_code == 404 +@pytest.mark.integration +def test_update_builtin_tool_type_fails() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type( + tool_type_id, + "builtin-tool", + "Built-in Tool", + "version: '3.8'\nservices:\n app:\n image: builtin", + is_builtin=True, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("access_token", _mint_token(user_id)) + payload = {"display_name": "Updated"} + response = client.put(f"/tool-types/{tool_type_id}", json=payload) + + assert response.status_code == 403 @pytest.mark.integration @@ -418,4 +442,53 @@ def test_delete_tool_type_not_found() -> None: assert response.status_code == 404 +@pytest.mark.integration +def test_delete_builtin_tool_type_fails() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type( + tool_type_id, + "builtin-tool", + "Built-in Tool", + "version: '3.8'\nservices:\n app:\n image: builtin", + is_builtin=True, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("access_token", _mint_token(user_id)) + response = client.delete(f"/tool-types/{tool_type_id}") + + assert response.status_code == 403 + + +@pytest.mark.integration +def test_builtin_tool_types_seeded_on_startup() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + # Load app triggers startup event which seeds built-in types + app = _load_app() + client = TestClient(app) + client.cookies.set("access_token", _mint_token(user_id)) + + response = client.get("/tool-types") + + assert response.status_code == 200 + data = response.json() + + # Check that built-in types exist + builtin_names = [t["name"] for t in data if t["is_builtin"]] + assert "code-server" in builtin_names + assert "jupyter-notebook" in builtin_names + + # Verify built-in types have correct attributes + code_server = next((t for t in data if t["name"] == "code-server"), None) + assert code_server is not None + assert code_server["display_name"] == "VS Code Server" + assert "services" in code_server["compose_template"] + assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"] diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py index 28c19a3..26141d0 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -1,3 +1,4 @@ +import uuid import pytest from fastapi.testclient import TestClient @@ -6,9 +7,7 @@ from fastapi.testclient import TestClient class TestToolTypesAPIExtended: """Integration tests for tool types API with new fields.""" - def test_create_tool_type_with_dockerfile( - self, authenticated_client: TestClient - ) -> None: + def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None: """Test creating a tool type with dockerfile definition.""" response = authenticated_client.post( "/tool-types", @@ -29,9 +28,7 @@ class TestToolTypesAPIExtended: assert data["definition_type"] == "dockerfile" assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask" - def test_create_tool_type_with_readiness_probe( - self, authenticated_client: TestClient - ) -> None: + def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None: """Test creating a tool type with readiness probe.""" response = authenticated_client.post( "/tool-types", @@ -42,7 +39,7 @@ class TestToolTypesAPIExtended: "interfaces": ["web"], "default_port": 8080, "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "readiness_probe": { "command": "curl -f http://localhost:8080", "timeout": 30, @@ -56,9 +53,7 @@ class TestToolTypesAPIExtended: assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080" assert data["readiness_probe"]["timeout"] == 30 - def test_create_tool_type_invalid_definition_type( - self, authenticated_client: TestClient - ) -> None: + def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None: """Test that invalid definition types are rejected.""" response = authenticated_client.post( "/tool-types", @@ -73,9 +68,7 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_create_tool_type_dockerfile_without_template( - self, authenticated_client: TestClient - ) -> None: + def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None: """Test that dockerfile type requires dockerfile_template.""" response = authenticated_client.post( "/tool-types", @@ -89,9 +82,7 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_update_tool_type_with_new_fields( - self, authenticated_client: TestClient - ) -> None: + def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None: """Test updating a tool type with new fields.""" # Create tool type first create_response = authenticated_client.post( @@ -101,7 +92,7 @@ class TestToolTypesAPIExtended: "display_name": "Update Test Tool", "default_port": 8080, "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'", + "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx", "required_variables": [], }, ) @@ -122,9 +113,7 @@ class TestToolTypesAPIExtended: assert response.status_code == 200 data = response.json() assert data["display_name"] == "Updated Name" - assert ( - data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" - ) + assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None: """Test validating compose template.""" @@ -139,9 +128,7 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_validate_tool_type_invalid_compose( - self, authenticated_client: TestClient - ) -> None: + def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None: """Test validating invalid compose template.""" response = authenticated_client.post( "/tool-types/validate", @@ -155,9 +142,7 @@ class TestToolTypesAPIExtended: assert data["valid"] is False assert "errors" in data - def test_validate_tool_type_dockerfile( - self, authenticated_client: TestClient - ) -> None: + def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None: """Test validating dockerfile template.""" response = authenticated_client.post( "/tool-types/validate", @@ -170,9 +155,7 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_get_tool_type_returns_new_fields( - self, authenticated_client: TestClient - ) -> None: + def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None: """Test that GET returns new fields.""" # Create tool type with all fields create_response = authenticated_client.post( @@ -184,7 +167,7 @@ class TestToolTypesAPIExtended: "interfaces": ["web", "terminal"], "default_port": 8443, "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", + "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "readiness_probe": { "command": "curl -f http://localhost:8443", "timeout": 30, @@ -203,124 +186,3 @@ class TestToolTypesAPIExtended: assert data["category"] == "editor" assert data["interfaces"] == ["web", "terminal"] assert "readiness_probe" in data - - def test_create_tool_type_without_port_fails( - self, authenticated_client: TestClient - ) -> None: - """Test that creating a tool type without default_port fails validation.""" - response = authenticated_client.post( - "/tool-types", - json={ - "name": "no-port-tool", - "display_name": "No Port Tool", - "category": "utility", - "interfaces": ["web"], - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'", - "required_variables": [], - }, - ) - assert response.status_code == 422 - data = response.json() - assert "default_port" in str(data) - - def test_create_tool_type_with_port_mismatch_fails( - self, authenticated_client: TestClient - ) -> None: - """Test that port mismatch between default_port and compose template fails.""" - response = authenticated_client.post( - "/tool-types", - json={ - "name": "port-mismatch-tool", - "display_name": "Port Mismatch Tool", - "category": "utility", - "interfaces": ["web"], - "default_port": 9999, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n ports:\n - '8080:8080'", - "required_variables": [], - }, - ) - assert response.status_code == 422 - _ = response.json() - - def test_create_tool_type_with_startup_command( - self, authenticated_client: TestClient - ) -> None: - """Test creating a tool type with startup_command.""" - response = authenticated_client.post( - "/tool-types", - json={ - "name": "startup-tool", - "display_name": "Startup Tool", - "category": "utility", - "interface_type": "terminal", - "requires_port": False, - "default_port": 0, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: alpine", - "startup_command": "cd /workspace && ls", - "required_variables": [], - }, - ) - assert response.status_code == 201 - data = response.json() - assert data["startup_command"] == "cd /workspace && ls" - assert data["interface_type"] == "terminal" - - def test_update_tool_type_startup_command( - self, authenticated_client: TestClient - ) -> None: - """Test updating a tool type's startup_command.""" - # Create tool type first - create_response = authenticated_client.post( - "/tool-types", - json={ - "name": "update-startup-tool", - "display_name": "Update Startup Tool", - "interface_type": "terminal", - "requires_port": False, - "default_port": 0, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: alpine", - "required_variables": [], - }, - ) - tool_id = create_response.json()["id"] - - # Update with startup_command - response = authenticated_client.put( - f"/tool-types/{tool_id}", - json={ - "startup_command": "source /etc/profile", - }, - ) - assert response.status_code == 200 - data = response.json() - assert data["startup_command"] == "source /etc/profile" - - def test_get_tool_type_returns_startup_command( - self, authenticated_client: TestClient - ) -> None: - """Test that GET returns startup_command.""" - create_response = authenticated_client.post( - "/tool-types", - json={ - "name": "get-startup-tool", - "display_name": "Get Startup Tool", - "interface_type": "terminal", - "requires_port": False, - "default_port": 0, - "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: alpine", - "startup_command": "echo hello", - "required_variables": [], - }, - ) - tool_id = create_response.json()["id"] - - response = authenticated_client.get(f"/tool-types/{tool_id}") - assert response.status_code == 200 - data = response.json() - assert data["startup_command"] == "echo hello" - assert "Port 9999 is not exposed" in str(data) diff --git a/apps/api/tests/integration/test_users_api.py b/apps/api/tests/integration/test_users_api.py index bb9a119..0ca300c 100644 --- a/apps/api/tests/integration/test_users_api.py +++ b/apps/api/tests/integration/test_users_api.py @@ -1,5 +1,5 @@ import uuid -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import asyncio import io @@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str: subject=user_id, email="test@headquarter.local", name="Test User", - expires_at=datetime.now(UTC) + timedelta(minutes=15), + expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) diff --git a/apps/api/tests/unit/test_git_url_parser.py b/apps/api/tests/unit/test_git_url_parser.py index 0f13801..07d48ea 100644 --- a/apps/api/tests/unit/test_git_url_parser.py +++ b/apps/api/tests/unit/test_git_url_parser.py @@ -1,5 +1,6 @@ """Tests for git URL parsing utilities.""" +import pytest from src.utils.git_url_parser import extract_base_repo_url, is_valid_clone_url, parse_git_url diff --git a/apps/api/tests/unit/test_migration_metadata.py b/apps/api/tests/unit/test_migration_metadata.py index c39b91e..311fae1 100644 --- a/apps/api/tests/unit/test_migration_metadata.py +++ b/apps/api/tests/unit/test_migration_metadata.py @@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None: assert module.revision == "0002_refresh_tokens" assert module.down_revision == "0001_initial_schema" + + +@pytest.mark.unit +def test_config_profiles_migration_has_expected_revision_chain() -> None: + migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py" + spec = spec_from_file_location("add_config_profiles", migration_path) + + assert spec is not None + assert spec.loader is not None + + module = module_from_spec(spec) + spec.loader.exec_module(module) + + assert module.revision == "0013_add_config_profiles" + assert module.down_revision == "0012_default_port_req" diff --git a/apps/api/tests/unit/test_profile_resolver.py b/apps/api/tests/unit/test_profile_resolver.py new file mode 100644 index 0000000..ac065a3 --- /dev/null +++ b/apps/api/tests/unit/test_profile_resolver.py @@ -0,0 +1,463 @@ +"""Unit tests for the profile resolver service.""" + +import uuid +from unittest.mock import MagicMock + +import pytest + +from src.services.profile_resolver import ( + ProfileCycleError, + ResolvedProfileOutput, + resolve_profile, +) + + +def _make_profile( + name: str, + env_vars: dict[str, str] | None = None, + start_command: str | None = None, + working_directory: str | None = None, + port: int | None = None, + mounts: list[MagicMock] | None = None, + includes: list[MagicMock] | None = None, +) -> MagicMock: + """Create a mock ConfigProfile for testing.""" + profile = MagicMock() + profile.id = uuid.uuid4() + profile.name = name + profile.environment_variables = env_vars or {} + profile.start_command = start_command + profile.working_directory = working_directory + profile.port = port + profile.mounts = mounts or [] + profile.includes = includes or [] + return profile + + +def _make_include(included_profile: MagicMock, order_index: int = 0) -> MagicMock: + """Create a mock ConfigInclude for testing.""" + include = MagicMock() + include.included_profile = included_profile + include.order_index = order_index + return include + + +def _make_mount( + target_path: str, + mode: str = "rw", + files: dict[str, str] | None = None, + order_index: int = 0, +) -> MagicMock: + """Create a mock ConfigMount for testing.""" + mount = MagicMock() + mount.target_path = target_path + mount.mode = mode + mount.files = files or {} + mount.order_index = order_index + return mount + + +class TestResolveProfileBasic: + """Tests for basic profile resolution without includes.""" + + def test_empty_profile(self) -> None: + """Resolving an empty profile returns empty output.""" + profile = _make_profile("empty") + result = resolve_profile(profile) + + assert isinstance(result, ResolvedProfileOutput) + assert result.profile_name == "empty" + assert result.environment_variables == {} + assert result.runtime_hints.start_command is None + assert result.runtime_hints.working_directory is None + assert result.runtime_hints.port is None + assert result.mounts == {} + assert result.resolution_order == ["empty"] + + def test_env_vars_only(self) -> None: + """Profile with env vars resolves correctly.""" + profile = _make_profile( + "env-only", + env_vars={"FOO": "bar", "BAZ": "qux"}, + ) + result = resolve_profile(profile) + + assert result.environment_variables == {"FOO": "bar", "BAZ": "qux"} + assert result.env_var_sources == { + "FOO": ["env-only"], + "BAZ": ["env-only"], + } + + def test_runtime_hints_only(self) -> None: + """Profile with runtime hints resolves correctly.""" + profile = _make_profile( + "hints-only", + start_command="python app.py", + working_directory="/app", + port=8080, + ) + result = resolve_profile(profile) + + assert result.runtime_hints.start_command == "python app.py" + assert result.runtime_hints.working_directory == "/app" + assert result.runtime_hints.port == 8080 + assert result.runtime_hints.overridden_hints == { + "start_command": "hints-only", + "working_directory": "hints-only", + "port": "hints-only", + } + + def test_mounts_only(self) -> None: + """Profile with mounts resolves correctly.""" + profile = _make_profile( + "mounts-only", + mounts=[ + _make_mount( + "/config", + mode="ro", + files={"settings.json": '{"key": "value"}'}, + ), + ], + ) + result = resolve_profile(profile) + + assert "/config" in result.mounts + mount = result.mounts["/config"] + assert mount.target_path == "/config" + assert mount.mode == "ro" + assert mount.files == {"settings.json": '{"key": "value"}'} + + +class TestResolveProfileIncludes: + """Tests for profile resolution with includes.""" + + def test_single_include(self) -> None: + """Profile with one include resolves in correct order.""" + base = _make_profile("base", env_vars={"FOO": "base"}) + derived = _make_profile( + "derived", + env_vars={"BAR": "derived"}, + includes=[_make_include(base, order_index=0)], + ) + result = resolve_profile(derived) + + assert result.resolution_order == ["derived", "base"] + assert result.environment_variables == { + "FOO": "base", + "BAR": "derived", + } + + def test_multiple_includes_ordered(self) -> None: + """Multiple includes are resolved in order_index order.""" + first = _make_profile("first", env_vars={"KEY": "first"}) + second = _make_profile("second", env_vars={"KEY": "second"}) + main = _make_profile( + "main", + includes=[ + _make_include(first, order_index=0), + _make_include(second, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.resolution_order == ["main", "first", "second"] + # second overrides first + assert result.environment_variables == {"KEY": "second"} + assert result.env_var_sources["KEY"] == ["first", "second"] + + def test_include_order_matters(self) -> None: + """Changing include order changes resolution.""" + a = _make_profile("a", env_vars={"KEY": "a"}) + b = _make_profile("b", env_vars={"KEY": "b"}) + main1 = _make_profile( + "main", + includes=[ + _make_include(a, order_index=0), + _make_include(b, order_index=1), + ], + ) + main2 = _make_profile( + "main", + includes=[ + _make_include(b, order_index=0), + _make_include(a, order_index=1), + ], + ) + + result1 = resolve_profile(main1) + result2 = resolve_profile(main2) + + assert result1.environment_variables["KEY"] == "b" + assert result2.environment_variables["KEY"] == "a" + + def test_nested_includes(self) -> None: + """Deeply nested includes resolve recursively.""" + deep = _make_profile("deep", env_vars={"DEEP": "value"}) + mid = _make_profile( + "mid", + env_vars={"MID": "value"}, + includes=[_make_include(deep, order_index=0)], + ) + top = _make_profile( + "top", + env_vars={"TOP": "value"}, + includes=[_make_include(mid, order_index=0)], + ) + result = resolve_profile(top) + + assert result.resolution_order == ["top", "mid", "deep"] + assert result.environment_variables == { + "TOP": "value", + "MID": "value", + "DEEP": "value", + } + + +class TestResolveProfileOverrides: + """Tests for deterministic override rules.""" + + def test_env_var_override(self) -> None: + """Later layers override earlier env vars.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + override = _make_profile("override", env_vars={"KEY": "override"}) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.environment_variables["KEY"] == "override" + assert result.env_var_sources["KEY"] == ["base", "override"] + + def test_main_profile_wins_over_includes(self) -> None: + """The main profile itself wins over all includes.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + main = _make_profile( + "main", + env_vars={"KEY": "main"}, + includes=[_make_include(base, order_index=0)], + ) + result = resolve_profile(main) + + assert result.environment_variables["KEY"] == "main" + assert result.env_var_sources["KEY"] == ["base", "main"] + + def test_runtime_hint_override(self) -> None: + """Later layers override earlier runtime hints.""" + base = _make_profile("base", start_command="python old.py") + override = _make_profile("override", start_command="python new.py") + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.runtime_hints.start_command == "python new.py" + assert result.runtime_hints.overridden_hints["start_command"] == "override" + + def test_mount_file_override(self) -> None: + """Later layers override earlier files in the same mount.""" + base = _make_profile( + "base", + mounts=[ + _make_mount( + "/config", + files={"app.json": '{"v": 1}'}, + ), + ], + ) + override = _make_profile( + "override", + mounts=[ + _make_mount( + "/config", + files={"app.json": '{"v": 2}'}, + ), + ], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + mount = result.mounts["/config"] + assert mount.files["app.json"] == '{"v": 2}' + assert mount.overridden_files["app.json"] == ["override"] + + def test_mount_mode_override(self) -> None: + """Later layers override mount mode.""" + base = _make_profile( + "base", + mounts=[_make_mount("/data", mode="ro")], + ) + override = _make_profile( + "override", + mounts=[_make_mount("/data", mode="rw")], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + assert result.mounts["/data"].mode == "rw" + assert result.mounts["/data"].mode_overridden_by == "override" + + def test_mount_file_merge(self) -> None: + """Different files in the same mount are merged.""" + base = _make_profile( + "base", + mounts=[ + _make_mount( + "/config", + files={"a.json": "1"}, + ), + ], + ) + override = _make_profile( + "override", + mounts=[ + _make_mount( + "/config", + files={"b.json": "2"}, + ), + ], + ) + main = _make_profile( + "main", + includes=[ + _make_include(base, order_index=0), + _make_include(override, order_index=1), + ], + ) + result = resolve_profile(main) + + mount = result.mounts["/config"] + assert mount.files == {"a.json": "1", "b.json": "2"} + + +class TestResolveProfileCycles: + """Tests for cycle detection during resolution.""" + + def test_direct_cycle(self) -> None: + """A -> B -> A is detected.""" + a = _make_profile("a") + b = _make_profile("b", includes=[_make_include(a, order_index=0)]) + a.includes = [_make_include(b, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert "a" in exc_info.value.cycle_path + assert "b" in exc_info.value.cycle_path + + def test_indirect_cycle(self) -> None: + """A -> B -> C -> A is detected.""" + a = _make_profile("a") + c = _make_profile("c") + b = _make_profile("b", includes=[_make_include(c, order_index=0)]) + a.includes = [_make_include(b, order_index=0)] + c.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert "a" in exc_info.value.cycle_path + assert "b" in exc_info.value.cycle_path + assert "c" in exc_info.value.cycle_path + + def test_self_cycle(self) -> None: + """A -> A is detected.""" + a = _make_profile("a") + a.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError) as exc_info: + resolve_profile(a) + + assert exc_info.value.cycle_path == ["a", "a"] + + def test_cycle_does_not_partially_resolve(self) -> None: + """Cycle detection prevents any partial resolution.""" + a = _make_profile("a", env_vars={"A": "a"}) + b = _make_profile("b", env_vars={"B": "b"}) + a.includes = [_make_include(b, order_index=0)] + b.includes = [_make_include(a, order_index=0)] + + with pytest.raises(ProfileCycleError): + resolve_profile(a) + + +class TestResolveProfileDiamond: + """Tests for diamond-shaped include graphs.""" + + def test_diamond_resolution(self) -> None: + """Diamond graph resolves correctly without duplication issues.""" + base = _make_profile("base", env_vars={"BASE": "base"}) + left = _make_profile( + "left", + env_vars={"LEFT": "left"}, + includes=[_make_include(base, order_index=0)], + ) + right = _make_profile( + "right", + env_vars={"RIGHT": "right"}, + includes=[_make_include(base, order_index=0)], + ) + top = _make_profile( + "top", + env_vars={"TOP": "top"}, + includes=[ + _make_include(left, order_index=0), + _make_include(right, order_index=1), + ], + ) + result = resolve_profile(top) + + # base should appear once (via left, then right skips because visited) + assert result.resolution_order == ["top", "left", "base", "right"] + assert result.environment_variables == { + "TOP": "top", + "LEFT": "left", + "RIGHT": "right", + "BASE": "base", + } + + def test_diamond_override(self) -> None: + """Diamond graph with conflicting overrides resolves correctly.""" + base = _make_profile("base", env_vars={"KEY": "base"}) + left = _make_profile( + "left", + env_vars={"KEY": "left"}, + includes=[_make_include(base, order_index=0)], + ) + right = _make_profile( + "right", + env_vars={"KEY": "right"}, + includes=[_make_include(base, order_index=0)], + ) + top = _make_profile( + "top", + includes=[ + _make_include(left, order_index=0), + _make_include(right, order_index=1), + ], + ) + result = resolve_profile(top) + + # right wins because it's later + assert result.environment_variables["KEY"] == "right" + assert result.env_var_sources["KEY"] == ["base", "left", "right"] + # Note: base appears once because visited set skips duplicate resolution in diamond graphs diff --git a/apps/api/tests/unit/test_readiness_probe.py b/apps/api/tests/unit/test_readiness_probe.py index 48dae89..0e3e75e 100644 --- a/apps/api/tests/unit/test_readiness_probe.py +++ b/apps/api/tests/unit/test_readiness_probe.py @@ -1,7 +1,9 @@ """Unit tests for readiness probe service.""" +import asyncio from unittest.mock import MagicMock, patch +import pytest from src.services.readiness_probe import execute_probe diff --git a/apps/api/tests/unit/test_terminal_manager.py b/apps/api/tests/unit/test_terminal_manager.py new file mode 100644 index 0000000..52c6838 --- /dev/null +++ b/apps/api/tests/unit/test_terminal_manager.py @@ -0,0 +1,112 @@ +"""Unit tests for TerminalManager.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.services.terminal_manager import TerminalManager +from src.services.terminal_session import TerminalSession + + +@pytest.fixture +def manager(): + return TerminalManager() + + +@pytest.fixture +def mock_websocket(): + ws = AsyncMock() + ws.send_bytes = AsyncMock() + ws.send_json = AsyncMock() + ws.close = AsyncMock() + ws.receive = AsyncMock() + return ws + + +@pytest.fixture +def mock_session(): + session = MagicMock(spec=TerminalSession) + session.session_id = "sess-123" + session.is_alive.return_value = True + session._closed = False + session.read_output = AsyncMock(return_value=b"") + session.write_input = AsyncMock() + session.resize = AsyncMock() + session.close = AsyncMock() + session.get_exit_reason.return_value = None + return session + + +class TestCreateSession: + @patch("src.services.terminal_manager.asyncio.create_task") + @patch("src.services.terminal_manager.uuid.uuid4", return_value="sess-123") + async def test_create_session_registers_and_starts_loops( + self, mock_uuid, mock_create_task, manager, mock_websocket + ): + instance_id = __import__("uuid").uuid4() + mock_sess = MagicMock() + mock_sess.session_id = "sess-123" + mock_sess.is_alive.return_value = True + mock_sess._closed = False + mock_sess.start = AsyncMock() + mock_sess.read_output = AsyncMock(return_value=b"") + mock_sess.write_input = AsyncMock() + mock_sess.resize = AsyncMock() + mock_sess.close = AsyncMock() + mock_sess.get_exit_reason.return_value = None + + with ( + patch.object(manager, "_read_loop", new=AsyncMock()), + patch.object(manager, "_write_loop", new=AsyncMock()), + patch.object(manager, "_heartbeat_loop", new=AsyncMock()), + patch( + "src.services.terminal_manager.TerminalSession", + return_value=mock_sess, + ), + ): + session = await manager.create_session( + instance_id, "container-abc", mock_websocket + ) + assert session.session_id == "sess-123" + assert "sess-123" in manager._sessions + assert "sess-123" in manager._last_client_message + + +class TestHandleControlMessage: + async def test_handle_resize(self, manager, mock_session, mock_websocket): + ctrl = {"type": "resize", "cols": 120, "rows": 40} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_session.resize.assert_awaited_once_with(120, 40) + + async def test_handle_ping(self, manager, mock_session, mock_websocket): + ctrl = {"type": "ping", "id": 42} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_websocket.send_json.assert_awaited_once_with({"type": "pong", "id": 42}) + + async def test_handle_unknown_type(self, manager, mock_session, mock_websocket): + ctrl = {"type": "unknown", "data": "test"} + await manager._handle_control_message(mock_session, mock_websocket, ctrl) + mock_websocket.send_json.assert_not_awaited() + mock_session.resize.assert_not_awaited() + + +class TestCleanupSession: + async def test_cleanup_removes_session(self, manager, mock_session): + manager._sessions["sess-123"] = mock_session + manager._last_client_message["sess-123"] = 123.0 + + await manager._cleanup_session(mock_session) + assert "sess-123" not in manager._sessions + assert "sess-123" not in manager._last_client_message + mock_session.close.assert_awaited_once() + + +class TestCloseAll: + async def test_close_all_clears_sessions(self, manager, mock_session): + manager._sessions["sess-123"] = mock_session + manager._last_client_message["sess-123"] = 123.0 + + await manager.close_all() + assert len(manager._sessions) == 0 + assert len(manager._last_client_message) == 0 + mock_session.close.assert_awaited_once() diff --git a/apps/api/tests/unit/test_terminal_session.py b/apps/api/tests/unit/test_terminal_session.py new file mode 100644 index 0000000..5dcbb75 --- /dev/null +++ b/apps/api/tests/unit/test_terminal_session.py @@ -0,0 +1,168 @@ +"""Unit tests for TerminalSession.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from src.services.terminal_session import TerminalSession + + +@pytest.fixture +def mock_pty(): + """Mock pty.openpty to return predictable fds.""" + master_fd = 10 + slave_fd = 11 + with ( + patch( + "src.services.terminal_session.pty.openpty", + return_value=(master_fd, slave_fd), + ), + patch("src.services.terminal_session.os.close") as mock_close, + ): + yield master_fd, slave_fd, mock_close + + +class TestTerminalSessionStart: + def test_init_state(self, mock_pty): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + + assert session.session_id == "sess-1" + assert session.container_id == "container-abc" + assert session._echo_enabled is True + assert session._exit_reason is None + + +class TestTerminalSessionEchoDetection: + @patch("src.services.terminal_session.termios.tcgetattr") + def test_detect_echo_state_enabled(self, mock_tcgetattr): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # termios.ECHO flag set + attrs = [[], [], [], __import__("termios").ECHO, [], [], []] + mock_tcgetattr.return_value = attrs + + result = session._detect_echo_state() + assert result is True + + @patch("src.services.terminal_session.termios.tcgetattr") + def test_detect_echo_state_disabled(self, mock_tcgetattr): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # termios.ECHO flag NOT set + attrs = [[], [], [], 0, [], [], []] + mock_tcgetattr.return_value = attrs + + result = session._detect_echo_state() + assert result is False + + def test_detect_echo_state_no_master_fd(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = None + + result = session._detect_echo_state() + assert result is True # default + + +class TestTerminalSessionResize: + @patch("src.services.terminal_session.fcntl.ioctl") + def test_resize_sets_size(self, mock_ioctl): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + # Should not raise + asyncio.run(session.resize(120, 40)) + mock_ioctl.assert_called_once() + + def test_resize_when_closed(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + asyncio.run(session.resize(120, 40)) + + +class TestTerminalSessionWriteInput: + @patch("src.services.terminal_session.os.write") + def test_write_input(self, mock_write): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + asyncio.run(session.write_input(b"hello")) + mock_write.assert_called_once_with(10, b"hello") + + def test_write_input_when_closed(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + asyncio.run(session.write_input(b"hello")) + + +class TestTerminalSessionReadOutput: + @patch("src.services.terminal_session.select.select") + @patch("src.services.terminal_session.os.read") + def test_read_output_with_data(self, mock_read, mock_select): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + mock_select.return_value = ([10], [], []) + mock_read.return_value = b"output" + + result = asyncio.run(session.read_output()) + assert result == b"output" + + @patch("src.services.terminal_session.select.select") + def test_read_output_no_data(self, mock_select): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + + mock_select.return_value = ([], [], []) + + result = asyncio.run(session.read_output()) + assert result == b"" + + +class TestTerminalSessionClose: + @patch("src.services.terminal_session.os.close") + @patch("src.services.terminal_session.asyncio.wait_for") + async def test_close_sets_exit_reason(self, mock_wait_for, mock_close): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._master_fd = 10 + session.process = MagicMock() + session.process.returncode = 0 + + await session.close() + assert session._exit_reason == "process_exit" + assert session._closed is True + + async def test_close_idempotent(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session._closed = True + + # Should not raise + await session.close() + + +class TestTerminalSessionIsAlive: + def test_is_alive_with_running_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = MagicMock() + session.process.returncode = None + + assert session.is_alive() is True + + def test_is_alive_with_exited_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = MagicMock() + session.process.returncode = 0 + + assert session.is_alive() is False + + def test_is_alive_no_process(self): + session = TerminalSession("sess-1", __import__("uuid").uuid4(), "container-abc") + session.process = None + + assert session.is_alive() is False diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index cfc213f..9f6c007 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -15,12 +15,6 @@ server { try_files $uri $uri/ /index.html; } - # Never cache index.html so browsers always fetch new hashed JS/CSS - location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; - } - # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 53e9cdf..8c3190d 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -19,8 +19,8 @@ "tailwindcss": "^3.3.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "xterm-addon-web-links": "^0.9.0", - "xterm-addon-webgl": "^0.16.0" + "xterm-addon-serialize": "^0.11.0", + "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -1391,6 +1391,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1408,6 +1411,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1425,6 +1431,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1442,6 +1451,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1459,6 +1471,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1476,6 +1491,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1654,6 +1672,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1668,6 +1689,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1682,6 +1706,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1696,6 +1723,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1710,6 +1740,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1724,6 +1757,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1738,6 +1774,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1752,6 +1791,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1766,6 +1808,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1780,6 +1825,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1794,6 +1842,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1808,6 +1859,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1822,6 +1876,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4304,6 +4361,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4325,6 +4385,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4346,6 +4409,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4367,6 +4433,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6304,21 +6373,21 @@ "xterm": "^5.0.0" } }, - "node_modules/xterm-addon-web-links": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz", - "integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.", + "node_modules/xterm-addon-serialize": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz", + "integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.", "license": "MIT", "peerDependencies": { "xterm": "^5.0.0" } }, - "node_modules/xterm-addon-webgl": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz", - "integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==", - "deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.", + "node_modules/xterm-addon-web-links": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz", + "integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==", + "deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.", "license": "MIT", "peerDependencies": { "xterm": "^5.0.0" diff --git a/apps/web/package.json b/apps/web/package.json index 9c081ac..a8332f3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,8 +22,8 @@ "tailwindcss": "^3.3.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", - "xterm-addon-web-links": "^0.9.0", - "xterm-addon-webgl": "^0.16.0" + "xterm-addon-serialize": "^0.11.0", + "xterm-addon-web-links": "^0.9.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/web/scripts/check-structure.js b/apps/web/scripts/check-structure.js new file mode 100644 index 0000000..eae68ec --- /dev/null +++ b/apps/web/scripts/check-structure.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/* eslint-disable */ +/** + * Verifies repository structure conventions. + * Run with: node scripts/check-structure.js + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC_DIR = path.join(__dirname, "..", "src"); + +let errors = 0; +let warnings = 0; + +// Known acceptable deviations — documented in naming.md +const OVERSIZE_ALLOWLIST = [ + // Form-heavy admin tabs: 15+ fields each, splitting would create micro-components + "components/features/tool-workshop/ToolTypesTab.tsx", + "components/features/tool-workshop/ToolConfigsTab.tsx", + // Complex terminal hook: WS lifecycle + ping-pong + echo + resize debouncing + "hooks/use-terminal-connection.ts", + // Terminal component: xterm lifecycle + resize observer + overlay UI + "components/features/terminal/TerminalComponent.tsx", + // Instance list with health polling + inline confirmations + "components/features/session/InstanceList.tsx", + // Dialog with form validation + SSH key handling + "components/features/project/RepositoryCreateDialog.tsx", + // Test files: complex test coverage + "hooks/use-terminal-connection.test.ts", + "pages/ToolWorkshopPage.test.tsx", + // Global utility CSS: will be further split in future iteration + "styles/utilities.css", +]; + +function checkFileSize(filePath, maxLines = 300) { + const content = fs.readFileSync(filePath, "utf-8"); + const lines = content.split("\n").length; + const relative = path.relative(SRC_DIR, filePath); + if (lines > maxLines) { + if (OVERSIZE_ALLOWLIST.includes(relative)) { + console.warn(`⚠️ OVERSIZED (${lines} lines, allowlisted): ${relative}`); + warnings++; + } else { + console.error(`❌ OVERSIZED (${lines} lines): ${relative}`); + errors++; + } + } +} + +function walk(dir, callback) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + walk(fullPath, callback); + } else { + callback(fullPath); + } + } +} + +console.log("Checking file sizes...\n"); +walk(SRC_DIR, (filePath) => { + const ext = path.extname(filePath); + if ([".ts", ".tsx", ".py", ".css"].includes(ext)) { + checkFileSize(filePath); + } +}); + +console.log("\n---"); +if (errors === 0 && warnings === 0) { + console.log("✅ All checks passed!"); + process.exit(0); +} else if (errors === 0) { + console.log(`✅ All checks passed with ${warnings} warning(s)`); + process.exit(0); +} else { + console.log(`❌ ${errors} error(s), ${warnings} warning(s)`); + process.exit(1); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1f27f88..8167416 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,4 @@ -import axios, { type AxiosRequestConfig } from "axios"; +import axios from "axios"; const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; @@ -14,38 +14,13 @@ export const shouldSkipAuthRedirect = (path: string): boolean => { return path.startsWith("/login") || path.startsWith("/auth"); }; -// Retry config for transient network errors -const MAX_RETRIES = 2; -const RETRY_DELAY_MS = 1000; - -// Track retry count per request -const retryCount = new WeakMap(); - apiClient.interceptors.response.use( (response) => response, - async (error) => { + (error) => { const status = error?.response?.status; if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) { window.location.assign(`${BASE_URL}/auth/login`); - return Promise.reject(error); } - - // Retry on transient network errors (ERR_NETWORK_CHANGED, etc.) - const isNetworkError = !error.response && error.message?.includes("Network"); - const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout - - if (isRetryable) { - const config = error.config; - const currentRetry = retryCount.get(config) || 0; - - if (currentRetry < MAX_RETRIES) { - retryCount.set(config, currentRetry + 1); - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1))); - return apiClient(config); - } - } - return Promise.reject(error); } ); diff --git a/apps/web/src/api/config-folders.test.ts b/apps/web/src/api/config-folders.test.ts new file mode 100644 index 0000000..7b5645a --- /dev/null +++ b/apps/web/src/api/config-folders.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createConfigFolder, + deleteConfigFolder, + listConfigFolders, + updateConfigFolder, +} from "../api/config-folders"; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPut = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock("../api/client", () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + delete: (...args: unknown[]) => mockDelete(...args), + interceptors: { + response: { + use: vi.fn(), + }, + }, + }, + shouldSkipAuthRedirect: vi.fn(() => false), +})); + +describe("config_folders API", () => { + describe("listConfigFolders", () => { + it("returns folders with files and overrides", async () => { + const mockResponse = { + data: [ + { + id: "folder-1", + name: "my-dotfiles", + description: "My personal config files", + mount_path: "/home/user", + files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }, + project_overrides: {}, + is_active: true, + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + ], + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await listConfigFolders(); + + expect(result[0].name).toBe("my-dotfiles"); + expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }); + expect(mockGet).toHaveBeenCalledWith("/config-folders"); + }); + }); + + describe("createConfigFolder", () => { + it("creates folder with files", async () => { + const mockResponse = { + data: { + id: "folder-new", + name: "new-folder", + mount_path: "/workspace", + files: { ".env": "API_URL=http://localhost" }, + is_active: true, + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }; + mockPost.mockResolvedValue(mockResponse); + + const result = await createConfigFolder({ + name: "new-folder", + mount_path: "/workspace", + files: { ".env": "API_URL=http://localhost" }, + }); + + expect(result.name).toBe("new-folder"); + expect(mockPost).toHaveBeenCalledWith( + "/config-folders", + expect.objectContaining({ + name: "new-folder", + mount_path: "/workspace", + }) + ); + }); + }); + + describe("updateConfigFolder", () => { + it("updates folder files", async () => { + const mockResponse = { + data: { + id: "folder-1", + name: "updated-folder", + mount_path: "/home/user", + files: { ".bashrc": "alias ll='ls -la'" }, + is_active: true, + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }; + mockPut.mockResolvedValue(mockResponse); + + const result = await updateConfigFolder("folder-1", { + files: { ".bashrc": "alias ll='ls -la'" }, + }); + + expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" }); + expect(mockPut).toHaveBeenCalledWith( + "/config-folders/folder-1", + expect.objectContaining({ + files: { ".bashrc": "alias ll='ls -la'" }, + }) + ); + }); + }); + + describe("deleteConfigFolder", () => { + it("deletes folder", async () => { + mockDelete.mockResolvedValue({ data: undefined }); + + await deleteConfigFolder("folder-1"); + + expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1"); + }); + }); +}); diff --git a/apps/web/src/api/config-folders.ts b/apps/web/src/api/config-folders.ts new file mode 100644 index 0000000..890111a --- /dev/null +++ b/apps/web/src/api/config-folders.ts @@ -0,0 +1,77 @@ +import { apiClient } from "./client"; +import type { + ConfigFolder, + CreateConfigFolderRequest, + UpdateConfigFolderRequest, + ProjectOverrideRequest, +} from "../types/config-folder"; + +export type { + ConfigFolder, + CreateConfigFolderRequest, + UpdateConfigFolderRequest, + ProjectOverrideRequest, +} from "../types/config-folder"; + +export const listConfigFolders = async (): Promise => { + const response = await apiClient.get("/config-folders"); + return response.data; +}; + +export const getConfigFolder = async (id: string): Promise => { + const response = await apiClient.get(`/config-folders/${id}`); + return response.data; +}; + +export const createConfigFolder = async ( + data: CreateConfigFolderRequest, +): Promise => { + const response = await apiClient.post("/config-folders", data); + return response.data; +}; + +export const updateConfigFolder = async ( + id: string, + data: UpdateConfigFolderRequest, +): Promise => { + const response = await apiClient.put( + `/config-folders/${id}`, + data, + ); + return response.data; +}; + +export const deleteConfigFolder = async (id: string): Promise => { + await apiClient.delete(`/config-folders/${id}`); +}; + +export const addProjectOverride = async ( + id: string, + projectId: string, + data: ProjectOverrideRequest, +): Promise => { + const response = await apiClient.post( + `/config-folders/${id}/overrides/${projectId}`, + data, + ); + return response.data; +}; + +export const updateProjectOverride = async ( + id: string, + projectId: string, + data: ProjectOverrideRequest, +): Promise => { + const response = await apiClient.put( + `/config-folders/${id}/overrides/${projectId}`, + data, + ); + return response.data; +}; + +export const deleteProjectOverride = async ( + id: string, + projectId: string, +): Promise => { + await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`); +}; diff --git a/apps/web/src/api/git-repositories.ts b/apps/web/src/api/git-repositories.ts new file mode 100644 index 0000000..a6a6c7d --- /dev/null +++ b/apps/web/src/api/git-repositories.ts @@ -0,0 +1,191 @@ +import { apiClient } from "./client"; +import type { + CommitDetail, + CommitHistoryResponse, + CommitResponse, + GitRepository, + GitRepositoryCreate, + GitStatus, + MergeResponse, + URLParseResult, +} from "../types/git-repository"; + +export type { + CommitDetail, + CommitHistoryEntry, + CommitHistoryResponse, + CommitResponse, + GitRepository, + GitRepositoryCreate, + GitStatus, + MergeResponse, + URLParseResult, +} from "../types/git-repository"; + +export async function parseGitUrl(url: string): Promise { + const response = await apiClient.post("/projects/repositories/parse-url", { + url, + }); + return response.data; +} + +export async function listRepositories( + projectId: string, +): Promise { + const response = await apiClient.get(`/projects/${projectId}/repositories`); + return response.data; +} + +export async function createRepository( + projectId: string, + data: GitRepositoryCreate, +): Promise { + const response = await apiClient.post( + `/projects/${projectId}/repositories`, + data, + ); + return response.data; +} + +export async function deleteRepository( + projectId: string, + repoId: string, +): Promise { + await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); +} + +export async function getRepositoryHistory( + projectId: string, + repoId: string, + branch?: string, + limit?: number, +): Promise { + const searchParams = new URLSearchParams(); + if (branch) searchParams.set("branch", branch); + if (limit) searchParams.set("limit", String(limit)); + const queryString = searchParams.toString(); + const params = queryString ? `?${queryString}` : ""; + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/history${params}`, + ); + return response.data; +} + +export async function getCommitDetail( + projectId: string, + repoId: string, + commitHash: string, +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`, + ); + return response.data; +} + +export async function getRepositoryStatus( + projectId: string, + repoId: string, +): Promise { + const response = await apiClient.get( + `/projects/${projectId}/repositories/${repoId}/status`, + ); + return response.data; +} + +export async function createBranch( + projectId: string, + repoId: string, + name: string, + baseBranch: string = "HEAD", +): Promise<{ message: string; branch: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/branches`, + { name, base_branch: baseBranch }, + ); + return response.data; +} + +export async function deleteBranch( + projectId: string, + repoId: string, + branchName: string, + force: boolean = false, +): Promise<{ message: string }> { + const response = await apiClient.delete( + `/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`, + ); + return response.data; +} + +export async function checkoutBranch( + projectId: string, + repoId: string, + branch: string, +): Promise<{ message: string; branch: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/checkout`, + { branch }, + ); + return response.data; +} + +export async function commitChanges( + projectId: string, + repoId: string, + message: string, + files?: string[], +): Promise { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/commit`, + { message, files }, + ); + return response.data; +} + +export async function fetchRepository( + projectId: string, + repoId: string, +): Promise<{ message: string }> { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/fetch`, + ); + return response.data; +} + +export async function pullRepository( + projectId: string, + repoId: string, + branch?: string, +): Promise<{ message: string }> { + const params = branch ? `?branch=${branch}` : ""; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/pull${params}`, + ); + return response.data; +} + +export async function pushRepository( + projectId: string, + repoId: string, + branch?: string, +): Promise<{ message: string }> { + const params = branch ? `?branch=${branch}` : ""; + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/push${params}`, + ); + return response.data; +} + +export async function mergeBranches( + projectId: string, + repoId: string, + sourceBranch: string, + targetBranch?: string, + message?: string, +): Promise { + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/merge`, + { source_branch: sourceBranch, target_branch: targetBranch, message }, + ); + return response.data; +} diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index 19ee39e..beaed85 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -1,54 +1,51 @@ import { apiClient } from "./client"; -import type { Project, ProjectWithRepos } from "../types"; +import type { Project } from "../types"; export type ProjectCreateInput = { - name: string; - description?: string | null; + name: string; + description?: string | null; }; export type ProjectUpdateInput = { - name?: string | null; - description?: string | null; + name?: string | null; + description?: string | null; }; export type SetDefaultSSHKeyInput = { - ssh_key_id: string; + ssh_key_id: string; }; -export const listProjects = async (): Promise => { - const response = await apiClient.get("/projects"); - return response.data; +export const listProjects = async (): Promise => { + const response = await apiClient.get("/projects"); + return response.data; }; export const createProject = async ( - input: ProjectCreateInput, + input: ProjectCreateInput ): Promise => { - const response = await apiClient.post("/projects", input); - return response.data; + const response = await apiClient.post("/projects", input); + return response.data; }; export const updateProject = async ( - projectId: string, - input: ProjectUpdateInput, + projectId: string, + input: ProjectUpdateInput ): Promise => { - const response = await apiClient.patch( - `/projects/${projectId}`, - input, - ); - return response.data; + const response = await apiClient.patch(`/projects/${projectId}`, input); + return response.data; }; export const deleteProject = async (projectId: string): Promise => { - await apiClient.delete(`/projects/${projectId}`); + await apiClient.delete(`/projects/${projectId}`); }; export const setDefaultSSHKey = async ( - projectId: string, - input: SetDefaultSSHKeyInput, + projectId: string, + input: SetDefaultSSHKeyInput ): Promise => { - const response = await apiClient.patch( - `/projects/${projectId}/default-ssh-key`, - input, - ); - return response.data; + const response = await apiClient.patch( + `/projects/${projectId}/default-ssh-key`, + input + ); + return response.data; }; diff --git a/apps/web/src/api/sessions.ts b/apps/web/src/api/sessions.ts index 751bf6e..2d4eaf2 100644 --- a/apps/web/src/api/sessions.ts +++ b/apps/web/src/api/sessions.ts @@ -1,39 +1,9 @@ -import { AxiosError } from "axios"; import { apiClient } from "./client"; +import type { Session } from "../types/session"; +import type { ToolInstance } from "../types/tool-instance"; -export interface ToolInstance { - id: string; - name: string; - display_name: string; - tool_type_id: string; - tool_type_name: string; - tool_type_interfaces: string[]; - status: string; - url: string | null; - port: number | null; - selected_config_profile_id: string | null; - ssh_key_ids: string[]; - created_at: string; -} - -export interface Session { - id: string; - display_name: string; - tool_type_name: string; - tool_icon: string; - tool_type_interfaces: string[]; - repository_name: string; - repository_id: string; - project_name: string; - project_id: string; - status: string; - url: string | null; - container_status?: string; - probe_status?: string; - clone_mode?: string; - branch?: string | null; - created_at?: string; -} +export type { Session } from "../types/session"; +export type { ToolInstance } from "../types/tool-instance"; export async function listInstances( projectId: string, @@ -50,24 +20,12 @@ export async function createInstance( repoId: string, toolTypeId: string, displayName?: string, - cloneMode?: string, - branch?: string, - newBranch?: string, - configProfileId?: string, - sshKeyIds?: string[], - workspaceId?: string, ): Promise { const response = await apiClient.post( `/projects/${projectId}/repositories/${repoId}/instances`, { tool_type_id: toolTypeId, display_name: displayName, - workspace_id: workspaceId || undefined, - clone_mode: cloneMode || "mount", - branch: branch || undefined, - new_branch: newBranch || undefined, - config_profile_id: configProfileId, - ssh_key_ids: sshKeyIds || [], }, ); return response.data; @@ -77,32 +35,11 @@ export async function startInstance( projectId: string, repoId: string, instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return startInstance( - projectId, - repoId, - instanceId, - configProfileId, - sshKeyIds, - retries - 1, - ); - } - throw error; - } + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`, + ); + return response.data; } export async function stopInstance( @@ -120,43 +57,20 @@ export async function restartInstance( projectId: string, repoId: string, instanceId: string, - configProfileId?: string, - sshKeyIds?: string[], - retries = 2, ): Promise<{ status: string; url?: string }> { - try { - const response = await apiClient.post( - `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, - { config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }, - ); - return response.data; - } catch (error) { - // Retry on network errors (e.g. Docker creating network interfaces) - const axiosError = error as AxiosError; - if (retries > 0 && !axiosError.response) { - await new Promise((r) => setTimeout(r, 1500)); - return restartInstance( - projectId, - repoId, - instanceId, - configProfileId, - sshKeyIds, - retries - 1, - ); - } - throw error; - } + const response = await apiClient.post( + `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`, + ); + return response.data; } export async function deleteInstance( projectId: string, repoId: string, instanceId: string, - force?: boolean, ): Promise { await apiClient.delete( `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`, - { params: { force } }, ); } @@ -165,23 +79,11 @@ export async function getUserSessions(): Promise { return response.data.sessions; } -export interface InstanceHealth { - healthy: boolean; - container_status: string; - container_health: string | null; - container_exit_code: number | null; - tunnel_status: string; - tunnel_status_code: number | null; - probe_status: string; - last_probe_output: string | null; - error: string | null; -} - export async function checkInstanceHealth( projectId: string, repoId: string, instanceId: string, -): Promise { +): Promise<{ healthy: boolean; status_code: number | null; error?: string }> { const response = await apiClient.get( `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`, ); diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts index 1bfa62e..af99e89 100644 --- a/apps/web/src/api/settings.ts +++ b/apps/web/src/api/settings.ts @@ -1,33 +1,27 @@ import { apiClient } from "./client"; export interface UserConfig { - default_editor: string | null; - theme: string; - git_user_name: string | null; - git_user_email: string | null; - last_session_id: string | null; - notification_toast_level?: "all" | "errors" | "none"; - notification_mute_categories?: string[]; + default_editor: string | null; + theme: string; + git_user_name: string | null; + git_user_email: string | null; + last_session_id: string | null; } export interface UserConfigUpdate { - default_editor?: string | null; - theme?: string | null; - git_user_name?: string | null; - git_user_email?: string | null; - last_session_id?: string | null; - notification_toast_level?: "all" | "errors" | "none"; - notification_mute_categories?: string[]; + default_editor?: string | null; + theme?: string | null; + git_user_name?: string | null; + git_user_email?: string | null; + last_session_id?: string | null; } export const getUserConfig = async (): Promise => { - const response = await apiClient.get("/users/me/config"); - return response.data; + const response = await apiClient.get("/users/me/config"); + return response.data; }; -export const updateUserConfig = async ( - data: UserConfigUpdate, -): Promise => { - const response = await apiClient.patch("/users/me/config", data); - return response.data; +export const updateUserConfig = async (data: UserConfigUpdate): Promise => { + const response = await apiClient.patch("/users/me/config", data); + return response.data; }; diff --git a/apps/web/src/api/ssh-keys.ts b/apps/web/src/api/ssh-keys.ts new file mode 100644 index 0000000..84061d7 --- /dev/null +++ b/apps/web/src/api/ssh-keys.ts @@ -0,0 +1,26 @@ +import { apiClient } from "./client"; + +export interface SSHKey { + id: string; + name: string; + public_key: string; + created_at: string; +} + +export interface SSHKeyCreate { + name: string; +} + +export async function listSSHKeys(): Promise { + const response = await apiClient.get("/ssh-keys"); + return response.data; +} + +export async function createSSHKey(data: SSHKeyCreate): Promise { + const response = await apiClient.post("/ssh-keys", data); + return response.data; +} + +export async function deleteSSHKey(keyId: string): Promise { + await apiClient.delete(`/ssh-keys/${keyId}`); +} diff --git a/apps/web/src/api/tool-configs.ts b/apps/web/src/api/tool-configs.ts new file mode 100644 index 0000000..3974f30 --- /dev/null +++ b/apps/web/src/api/tool-configs.ts @@ -0,0 +1,52 @@ +import { apiClient } from "./client"; +import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config"; + +export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config"; + +export const listToolConfigs = async ( + tool_type_id?: string, + project_id?: string, +): Promise => { + const params = new URLSearchParams(); + if (tool_type_id) params.append("tool_type_id", tool_type_id); + if (project_id) params.append("project_id", project_id); + + const response = await apiClient.get<{ configs: ToolConfig[] }>( + `/tool-configs?${params.toString()}`, + ); + return response.data.configs; +}; + +export const createToolConfig = async ( + data: CreateToolConfigRequest, +): Promise => { + const response = await apiClient.post<{ configs: ToolConfig[] }>( + "/tool-configs", + data, + ); + return response.data.configs[0]; +}; + +export const updateToolConfig = async ( + id: string, + data: CreateToolConfigRequest, +): Promise => { + const response = await apiClient.put<{ configs: ToolConfig[] }>( + `/tool-configs/${id}`, + data, + ); + return response.data.configs[0]; +}; + +export const deleteToolConfig = async (id: string): Promise => { + await apiClient.delete(`/tool-configs/${id}`); +}; + +export const getToolConfigDefaults = async ( + toolTypeId: string, +): Promise => { + const response = await apiClient.get( + `/tool-configs/defaults/${toolTypeId}`, + ); + return response.data; +}; diff --git a/apps/web/src/api/tool-types.test.ts b/apps/web/src/api/tool-types.test.ts new file mode 100644 index 0000000..0acc59c --- /dev/null +++ b/apps/web/src/api/tool-types.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createToolType, + deleteToolType, + listToolTypes, + updateToolType, + validateToolType, +} from "../api/tool-types"; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPut = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock("../api/client", () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + delete: (...args: unknown[]) => mockDelete(...args), + interceptors: { + response: { + use: vi.fn(), + }, + }, + }, + shouldSkipAuthRedirect: vi.fn(() => false), +})); + +describe("tool_types API", () => { + describe("listToolTypes", () => { + it("returns tool types with new fields", async () => { + const mockResponse = { + data: [ + { + id: "type-1", + name: "custom-tool", + display_name: "Custom Tool", + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + readiness_probe: { + command: "python --version", + timeout: 30, + interval: 2, + }, + build_context: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + ], + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await listToolTypes(); + + expect(result[0].definition_type).toBe("dockerfile"); + expect(result[0].dockerfile_template).toBe("FROM python:3.11"); + expect(result[0].readiness_probe).toEqual({ + command: "python --version", + timeout: 30, + interval: 2, + }); + }); + + it("returns compose tool types", async () => { + const mockResponse = { + data: [ + { + id: "type-1", + name: "code-server", + definition_type: "compose", + compose_template: "version: '3.8'", + dockerfile_template: null, + build_context: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + ], + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await listToolTypes(); + + expect(result[0].definition_type).toBe("compose"); + expect(result[0].dockerfile_template).toBeNull(); + }); + }); + + describe("createToolType", () => { + it("creates tool type with dockerfile", async () => { + const mockResponse = { + data: { + id: "new-type", + name: "docker-tool", + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + build_context: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }; + mockPost.mockResolvedValue(mockResponse); + + const result = await createToolType({ + name: "docker-tool", + display_name: "Docker Tool", + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + default_port: 3000, + required_variables: [], + }); + + expect(result.definition_type).toBe("dockerfile"); + expect(mockPost).toHaveBeenCalledWith( + "/tool-types", + expect.objectContaining({ + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + }) + ); + }); + + it("creates tool type with readiness probe", async () => { + const mockResponse = { + data: { + id: "new-type", + name: "probed-tool", + readiness_probe: { + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }, + build_context: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }; + mockPost.mockResolvedValue(mockResponse); + + const result = await createToolType({ + name: "probed-tool", + display_name: "Probed Tool", + compose_template: "version: '3.8'", + default_port: 8080, + required_variables: [], + readiness_probe: { + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }, + }); + + expect(result.readiness_probe).toEqual({ + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }); + }); + }); + + describe("validateToolType", () => { + it("validates tool type by id", async () => { + const mockResponse = { + data: { valid: true, errors: [] }, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await validateToolType("type-1"); + + expect(result.valid).toBe(true); + expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate"); + }); + + it("returns validation errors", async () => { + const mockResponse = { + data: { valid: false, errors: ["Invalid YAML"] }, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await validateToolType("type-1"); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("Invalid YAML"); + }); + }); + + describe("updateToolType", () => { + it("updates tool type with new fields", async () => { + const mockResponse = { + data: { + id: "type-1", + name: "updated-tool", + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + build_context: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }, + }; + mockPut.mockResolvedValue(mockResponse); + + const result = await updateToolType("type-1", { + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + }); + + expect(result.definition_type).toBe("dockerfile"); + expect(mockPut).toHaveBeenCalledWith( + "/tool-types/type-1", + expect.objectContaining({ + definition_type: "dockerfile", + }) + ); + }); + }); + + describe("deleteToolType", () => { + it("deletes tool type", async () => { + mockDelete.mockResolvedValue({ data: undefined }); + + await deleteToolType("type-1"); + + expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1"); + }); + }); +}); diff --git a/apps/web/src/api/tool-types.ts b/apps/web/src/api/tool-types.ts new file mode 100644 index 0000000..54d3fa7 --- /dev/null +++ b/apps/web/src/api/tool-types.ts @@ -0,0 +1,51 @@ +import { apiClient } from "./client"; +import type { + ToolType, + CreateToolTypeRequest, + UpdateToolTypeRequest, +} from "../types/tool-type"; + +export type { + ReadinessProbe, + ToolType, + CreateToolTypeRequest, + UpdateToolTypeRequest, +} from "../types/tool-type"; + +export const listToolTypes = async (): Promise => { + const response = await apiClient.get("/tool-types"); + return response.data; +}; + +export const getToolType = async (id: string): Promise => { + const response = await apiClient.get(`/tool-types/${id}`); + return response.data; +}; + +export const createToolType = async ( + data: CreateToolTypeRequest, +): Promise => { + const response = await apiClient.post("/tool-types", data); + return response.data; +}; + +export const updateToolType = async ( + id: string, + data: UpdateToolTypeRequest, +): Promise => { + const response = await apiClient.put(`/tool-types/${id}`, data); + return response.data; +}; + +export const deleteToolType = async (id: string): Promise => { + await apiClient.delete(`/tool-types/${id}`); +}; + +export const validateToolType = async ( + id: string, +): Promise<{ valid: boolean; errors?: string[] }> => { + const response = await apiClient.get<{ valid: boolean; errors?: string[] }>( + `/tool-types/${id}/validate`, + ); + return response.data; +}; diff --git a/apps/web/src/components/ProtectedRoute.test.tsx b/apps/web/src/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..a0194d5 --- /dev/null +++ b/apps/web/src/components/ProtectedRoute.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +import { ProtectedRoute } from "./ProtectedRoute"; + +const mockUseAuth = vi.fn(); + +vi.mock("../state/auth", () => ({ + useAuth: () => mockUseAuth() +})); + +describe("ProtectedRoute", () => { + it("shows loading while session is resolving", () => { + mockUseAuth.mockReturnValue({ state: "loading" }); + + render( + + +
private content
+
+
+ ); + + expect(screen.getByText("Checking session...")).toBeInTheDocument(); + }); + + it("redirects unauthenticated users to login", () => { + mockUseAuth.mockReturnValue({ state: "unauthenticated" }); + + render( + + + +
private content
+ + } + /> + login page} /> +
+
+ ); + + expect(screen.getByText("login page")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ProtectedRoute.tsx b/apps/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..c0275dd --- /dev/null +++ b/apps/web/src/components/ProtectedRoute.tsx @@ -0,0 +1,19 @@ +import { Navigate, useLocation } from "react-router-dom"; + +import { useAuth } from "../state/auth"; + +export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { state } = useAuth(); + const location = useLocation(); + + if (state === "loading") { + return
Checking session...
; + } + + if (state === "unauthenticated") { + const nextPath = encodeURIComponent(location.pathname); + return ; + } + + return <>{children}; +}; diff --git a/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx b/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx new file mode 100644 index 0000000..ecca9b2 --- /dev/null +++ b/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx @@ -0,0 +1,86 @@ +import type { Session } from "../../../types/session"; +import { Icon } from "../../ui/Icon"; + +interface ActiveSessionsListProps { + sessions: Session[]; + actionBusy: string | null; + onOpen: (session: Session) => void; + onStop: (session: Session) => void; + onDelete: (session: Session) => void; + onRecreateTunnel: (session: Session) => void; +} + +export const ActiveSessionsList = ({ + sessions, + actionBusy, + onOpen, + onStop, + onDelete, + onRecreateTunnel, +}: ActiveSessionsListProps) => { + if (sessions.length === 0) { + return

No active sessions right now.

; + } + + return ( +
+ {sessions.map((session) => ( +
+
+
+

+ {session.display_name || + session.tool_type_name || + "Unnamed Session"} +

+ + {session.status} + +
+

+ {session.project_name} · {session.repository_name} +

+

{session.tool_type_name}

+
+
+ + + + +
+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/DashboardSummary.tsx b/apps/web/src/components/features/dashboard/DashboardSummary.tsx new file mode 100644 index 0000000..004fa5e --- /dev/null +++ b/apps/web/src/components/features/dashboard/DashboardSummary.tsx @@ -0,0 +1,34 @@ +import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard"; + +interface DashboardSummaryProps { + summary: DashboardSummaryType; + activeSessionsCount: number; +} + +const summaryCards = [ + { label: "Open sessions", key: "openSessions" }, + { label: "Projects", key: "projects" }, + { label: "Repositories", key: "repositories" }, +] as const; + +export const DashboardSummary = ({ + summary, + activeSessionsCount, +}: DashboardSummaryProps) => { + return ( +
+ {summaryCards.map((card) => ( +
+

{card.label}

+

+ {card.key === "openSessions" + ? activeSessionsCount + : card.key === "projects" + ? summary.projects + : summary.repositories} +

+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/ProjectsSection.tsx b/apps/web/src/components/features/dashboard/ProjectsSection.tsx new file mode 100644 index 0000000..d8a3c80 --- /dev/null +++ b/apps/web/src/components/features/dashboard/ProjectsSection.tsx @@ -0,0 +1,40 @@ +import type { Project } from "../../../types/project"; + +interface ProjectsSectionProps { + projects: Project[]; + onOpenProject: (projectId: string) => void; +} + +export const ProjectsSection = ({ + projects, + onOpenProject, +}: ProjectsSectionProps) => { + if (projects.length === 0) { + return

No projects yet.

; + } + + return ( +
+ {projects.map((project) => ( +
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ +
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/QuickCreateForm.tsx b/apps/web/src/components/features/dashboard/QuickCreateForm.tsx new file mode 100644 index 0000000..51d8bab --- /dev/null +++ b/apps/web/src/components/features/dashboard/QuickCreateForm.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import type { Project } from "../../../types/project"; +import type { GitRepository } from "../../../types/git-repository"; +import type { ToolType } from "../../../types/tool-type"; +import { Icon } from "../../ui/Icon"; + +interface QuickCreateFormProps { + projects: Project[]; + repositories: GitRepository[]; + toolTypes: ToolType[]; + saveState: "idle" | "saving" | "error"; + onSubmit: (data: { + projectId: string; + repoId: string; + toolTypeId: string; + displayName: string; + }) => void; + onProjectChange: (projectId: string) => void; +} + +export const QuickCreateForm = ({ + projects, + repositories, + toolTypes, + saveState, + onSubmit, + onProjectChange, +}: QuickCreateFormProps) => { + const [selectedProject, setSelectedProject] = useState(""); + const [selectedRepo, setSelectedRepo] = useState(""); + const [selectedToolType, setSelectedToolType] = useState(""); + const [displayName, setDisplayName] = useState(""); + + const handleProjectChange = (projectId: string) => { + setSelectedProject(projectId); + setSelectedRepo(""); + onProjectChange(projectId); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedProject || !selectedRepo || !selectedToolType) return; + onSubmit({ + projectId: selectedProject, + repoId: selectedRepo, + toolTypeId: selectedToolType, + displayName, + }); + }; + + return ( +
+
+ + + +
+ +
+ + {saveState === "error" && ( + Failed to create session + )} +
+
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx b/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx new file mode 100644 index 0000000..8e0852d --- /dev/null +++ b/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx @@ -0,0 +1,47 @@ +import type { Session } from "../../../types/session"; + +interface RecentSessionsSectionProps { + sessions: Session[]; + onOpen: (session: Session) => void; +} + +export const RecentSessionsSection = ({ + sessions, + onOpen, +}: RecentSessionsSectionProps) => { + if (sessions.length === 0) return null; + + return ( +
+
+
+

Recent sessions

+

{sessions.length}

+
+
+
+ {sessions.map((session) => ( +
+
+ + {session.display_name || + session.tool_type_name || + "Unnamed Session"} + + + {session.project_name} · {session.tool_type_name} + +
+ +
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/index.ts b/apps/web/src/components/features/dashboard/index.ts new file mode 100644 index 0000000..4dc0c78 --- /dev/null +++ b/apps/web/src/components/features/dashboard/index.ts @@ -0,0 +1,5 @@ +export { DashboardSummary } from "./DashboardSummary"; +export { ActiveSessionsList } from "./ActiveSessionsList"; +export { ProjectsSection } from "./ProjectsSection"; +export { QuickCreateForm } from "./QuickCreateForm"; +export { RecentSessionsSection } from "./RecentSessionsSection"; diff --git a/apps/web/src/components/features/git/CommitDialog.module.css b/apps/web/src/components/features/git/CommitDialog.module.css new file mode 100644 index 0000000..3105bf3 --- /dev/null +++ b/apps/web/src/components/features/git/CommitDialog.module.css @@ -0,0 +1,130 @@ +.commitDialog { + background: var(--panel); + border-radius: 14px; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow: auto; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); +} + +.dialogHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.dialogHeader h3 { + margin: 0; + font-size: 1.1rem; +} + +.dialogClose { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--muted); + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; +} + +.dialogClose:hover { + background: var(--bg); + color: var(--ink); +} + +.dialogBody { + padding: 1.5rem; +} + +.fileInfo { + margin: 0 0 1rem; + color: var(--muted); +} + +.diffPreview { + margin-bottom: 1.5rem; +} + +.diffPreview h4 { + margin: 0 0 0.75rem; + font-size: 0.9rem; + color: var(--muted); +} + +.diffContent { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: auto; + max-height: 300px; + font-family: 'Fira Code', 'Monaco', 'Courier New', monospace; + font-size: 13px; +} + +.diffLine { + display: flex; + padding: 0.15rem 0.5rem; + gap: 0.5rem; +} + +.diffLineNumber { + color: var(--muted); + min-width: 2rem; + text-align: right; + user-select: none; +} + +.diffMarker { + width: 1rem; + text-align: center; + font-weight: bold; +} + +.diffAdded { + background: rgba(16, 185, 129, 0.1); +} + +.diffAdded .diffMarker { + color: #059669; +} + +.diffRemoved { + background: rgba(239, 68, 68, 0.1); +} + +.diffRemoved .diffMarker { + color: #dc2626; +} + +.diffSame { + background: transparent; +} + +.diffLineContent { + flex: 1; +} + +.warningMessage { + padding: 0.75rem; + background: rgba(245, 158, 11, 0.1); + color: #d97706; + border-radius: 8px; + margin-bottom: 1rem; +} + +.dialogFooter { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border); +} diff --git a/apps/web/src/components/features/git/CommitDialog.tsx b/apps/web/src/components/features/git/CommitDialog.tsx new file mode 100644 index 0000000..28957eb --- /dev/null +++ b/apps/web/src/components/features/git/CommitDialog.tsx @@ -0,0 +1,160 @@ +import styles from "./CommitDialog.module.css"; +import React, { useState } from "react"; + +import { Icon } from "../../ui/Icon"; + +interface CommitDialogProps { + isOpen: boolean; + filePath: string; + originalContent: string; + newContent: string; + onCommit: (message: string) => Promise; + onCancel: () => void; +} + +export const CommitDialog: React.FC = ({ + isOpen, + filePath, + originalContent, + newContent, + onCommit, + onCancel, +}) => { + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(">"); + + if (!isOpen) return null; + + const generateDiff = () => { + const originalLines = originalContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLines = Math.max(originalLines.length, newLines.length); + const diff: { type: "same" | "added" | "removed"; line: string; lineNum: number }[] = []; + + for (let i = 0; i < maxLines; i++) { + const original = originalLines[i] || ""; + const updated = newLines[i] || ""; + + if (original === updated) { + diff.push({ type: "same", line: updated, lineNum: i + 1 }); + } else { + if (original) { + diff.push({ type: "removed", line: original, lineNum: i + 1 }); + } + if (updated) { + diff.push({ type: "added", line: updated, lineNum: i + 1 }); + } + } + } + + return diff; + }; + + const handleCommit = async () => { + if (!message.trim()) { + setError("Please enter a commit message"); + return; + } + + setLoading(true); + setError(""); + try { + await onCommit(message); + } catch { + setError("Failed to commit changes"); + } finally { + setLoading(false); + } + }; + + const diff = generateDiff(); + const hasChanges = diff.some((d) => d.type !== "same"); + + return ( +
+
+
+

Commit Changes

+ +
+ +
+

+ Editing: {filePath} +

+ + {!hasChanges && ( +
No changes to commit
+ )} + + {hasChanges && ( +
+

Changes

+
+ {diff.map((line, i) => ( +
+ {line.lineNum} + + {line.type === "added" && "+"} + {line.type === "removed" && "-"} + {line.type === "same" && " "} + + {line.line} +
+ ))} +
+
+ )} + +
+ +